feat: initial open-source release of htr-guard (Rust) & htr-site (Go) under MIT License
This commit is contained in:
commit
51d1b93d06
15 changed files with 5636 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/rust-guard/target/
|
||||
/htr-site
|
||||
/dist/
|
||||
*.tmp
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 Vahap Eren <vahap@hacker.tr>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
83
README.md
Normal file
83
README.md
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# htr-guard & htr-site — Memory-Safe Coordinated Vulnerability Disclosure (CVD) Engine
|
||||
|
||||
**License:** MIT (`SPDX-License-Identifier: MIT`)
|
||||
**Repository:** `https://git.hacker.tr/vahap/htr-guard`
|
||||
**Live Deployment:** `https://hacker.tr`
|
||||
**Maintainer:** Vahap Eren (`vahap@hacker.tr`) · OpenPGP `57DB BB2A FF1C B754 E9BF C5C5 44CF 7758 88CF 2883`
|
||||
|
||||
`htr-guard` is an open-source, zero-dependency, memory-safe Coordinated Vulnerability Disclosure (CVD) build gate and static advisory publishing engine written in **Rust** (`#![forbid(unsafe_code)]`) and **Go** (standard library only).
|
||||
|
||||
It is designed for independent vulnerability researchers, open-source project PSIRTs, and defensive security labs who want to publish permanent, verifiable security advisories (`HTR-YYYY-NNN` / `CVE-YYYY-NNNNN`) without risking accidental pre-patch leaks of affected product names, versions, unassigned CVE identifiers, or proof-of-concept exploit details, and without exposing any dynamic server attack surface.
|
||||
|
||||
---
|
||||
|
||||
## Why This Exists
|
||||
|
||||
Publishing coordinated security advisories safely has two recurring failure modes:
|
||||
1. **Accidental Pre-Patch Metadata Leakage:** Static site generators typically render Markdown files directly; a single forgotten front-matter field or summary line can leak an unpatched vendor/product name or an unassigned CVE ID before a fix ships.
|
||||
2. **CMS & Client-Side Attack Surface:** Hosting security advisories on dynamic panels, databases, web forms, or third-party JavaScript bundles creates an unnecessary attack surface on the researcher's own infrastructure.
|
||||
|
||||
`htr-guard` solves both at build time through a **fail-closed Rust security gate** paired with a **zero-JavaScript multi-language Go renderer**.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### 1. `rust-guard/` — `htr-guard` (`#![forbid(unsafe_code)]`, 0 external crates)
|
||||
- **Fail-Closed Staged Disclosure Redaction (`htr-guard gate`):**
|
||||
- `reported` / `cve_requested`: Strips `product`, `affected_versions`, `severity`, `cvss`, `timeline`, and the Markdown advisory body in memory before rendering. Aborts the build (`exit 1`) if an unassigned `cve_id` is present or if `internal_vendor` / `internal_product` substrings leak into any public vulnerability class field.
|
||||
- `cve_reserved`: Permits only a syntactically valid assigned `CVE-YYYY-NNNN+` identifier while keeping vendor, product, version, and technical PoC details stripped until remediation.
|
||||
- `published`: Validates mandatory publication metadata (`published_date`, `product`, `affected_versions`, `severity`) and emits full localized technical advisories.
|
||||
- **Static Output Security Auditor & FIPS 180-4 SHA-256 Sealer (`htr-guard seal`):**
|
||||
- Parses every generated `.html` file in `dist/` and aborts deployment if any `<form>`, `<input>`, `<textarea>`, `<iframe>`, `<object>`, `<embed>`, inline `style=""` attribute (enforcing strict `Content-Security-Policy: default-src 'none'; style-src 'self'`), third-party font/CDN/analytics reference, or executable `<script>` tag is present (allowing solely `<script type="application/ld+json">`).
|
||||
- Verifies RFC 9116 compliance on `/.well-known/security.txt`.
|
||||
- Computes a pure-Rust FIPS 180-4 SHA-256 manifest (`SHA256SUMS.txt`) over every output artifact prior to OpenPGP clearsigning.
|
||||
|
||||
### 2. `go-site/` — `htr-site` (Go `html/template`, 0 external modules)
|
||||
- Consumes only the sanitized JSON bundle emitted by `htr-guard gate`—never raw unredacted advisory Markdown.
|
||||
- Renders **8 canonical pages** across **9 languages** (`en`, `fr`, `de`, `tr`, `pt`, `it`, `ru`, `zh`, `es`) with full `hreflang` + `x-default` cross-linking, content-hashed CSS (`/assets/style.<sha256>.css`), Schema.org JSON-LD (`ProfilePage`, `BreadcrumbList`, `TechArticle`), localized Atom XML feeds, `sitemap.xml`, `/pgp.asc`, and RFC 9116 `/.well-known/security.txt`.
|
||||
- Pairs with static **OpenPGP Web Key Directory (WKD)** export (`/.well-known/openpgpkey/hu/...`) in `deploy/rebuild.sh` so mail clients (`gpg --locate-external-keys`, Thunderbird, ProtonMail) automatically discover encryption keys over HTTPS.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start & Running Tests
|
||||
|
||||
### Prerequisites
|
||||
- Rust toolchain (`rustc` / `cargo` >= 1.75)
|
||||
- Go toolchain (`go` >= 1.22)
|
||||
- Optional: `gnupg` (`gpg`) for RFC 9116 `security.txt` clearsigning, `SHA256SUMS.txt` signing, and OpenPGP WKD export
|
||||
|
||||
### Run the Rust Redaction & Security Gate Unit Tests
|
||||
```bash
|
||||
cargo test --manifest-path rust-guard/Cargo.toml
|
||||
```
|
||||
|
||||
### Build & Render Locally
|
||||
```bash
|
||||
# 1. Build binaries
|
||||
cargo build --release --manifest-path rust-guard/Cargo.toml
|
||||
(cd go-site && go build -trimpath -ldflags="-s -w" -o ../htr-site .)
|
||||
|
||||
# 2. Run fail-closed redaction gate -> sanitized JSON bundle
|
||||
./rust-guard/target/release/htr-guard gate ./content /tmp/bundle.json
|
||||
|
||||
# 3. Render multi-language static output
|
||||
./htr-site /tmp/bundle.json /tmp/dist
|
||||
|
||||
# 4. Audit HTML output (CSP / zero-JS / zero-form) & generate SHA-256 manifest
|
||||
./rust-guard/target/release/htr-guard seal /tmp/dist /tmp/dist/SHA256SUMS.txt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Supporting Open-Source Security Research
|
||||
|
||||
All vulnerability disclosures to open-source maintainers and software vendors are performed **100% free of charge** under a 90-day Coordinated Vulnerability Disclosure policy (`https://hacker.tr/disclosure/`).
|
||||
|
||||
Community funding for local laboratory hardware (offline vulnerability pattern model training and memory-safety fuzzing without external cloud code leakage) is managed transparently through Open Collective (`https://opencollective.com/hacker-tr`) with every hardware invoice published on `https://hacker.tr/transparency/`.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Released under the [MIT License](./LICENSE).
|
||||
34
content/advisories/README.md
Normal file
34
content/advisories/README.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Advisories Directory (`content/advisories/`)
|
||||
|
||||
Add one Markdown file per vulnerability disclosure named `HTR-YYYY-NNN.md` (for example `HTR-2026-001.md`).
|
||||
|
||||
## Staged Disclosure Security Gate (`htr-guard`)
|
||||
|
||||
`htr-guard` (Rust) enforces strict fail-closed redaction:
|
||||
- `status: reported` -> Only `HTR-YYYY-NNN`, generic `class_<lang>`, `reported_date`, and "Coordinated disclosure in progress" are emitted. Product, version, CVSS, timeline, and technical body are stripped in memory. Setting `cve_id` in this state aborts the build.
|
||||
- `status: cve_requested` -> Same as `reported`, plus "CVE assignment requested". Setting `cve_id` before assignment aborts the build.
|
||||
- `status: cve_reserved` -> Requires assigned `cve_id: CVE-YYYY-NNNN`. Product, version, and technical details remain stripped until vendor patch or disclosure deadline.
|
||||
- `status: published` -> Exposes `product`, `affected_versions`, `severity`, `cvss`, `published_date`, `timeline_<lang>`, and technical summary.
|
||||
|
||||
## Template (`HTR-2026-001.md.example`)
|
||||
|
||||
```markdown
|
||||
---
|
||||
id: HTR-2026-001
|
||||
status: reported
|
||||
reported_date: 2026-11-15
|
||||
updated_date: 2026-11-15
|
||||
internal_vendor: RedactedVendorName
|
||||
internal_product: RedactedProductName
|
||||
class_en: Authentication Bypass in Web Management Interface
|
||||
class_tr: Web Yönetim Arayüzünde Kimlik Doğrulama Atlatma
|
||||
class_fr: Contournement d'authentification dans une interface d'administration web
|
||||
class_de: Authentifizierungsumgehung in einer Web-Verwaltungsoberfläche
|
||||
class_es: Evasión de autenticación en interfaz de administración web
|
||||
class_pt: Contorno de autenticação em interface de gerenciamento web
|
||||
class_it: Bypass dell'autenticazione in interfaccia di gestione web
|
||||
class_ru: Обход аутентификации в веб-интерфейсе управления
|
||||
class_zh: Web 管理界面身份验证绕过
|
||||
---
|
||||
Private technical notes or draft summary (automatically stripped by Rust `htr-guard` until `status: published`).
|
||||
```
|
||||
13
content/pgp-public.asc
Normal file
13
content/pgp-public.asc
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
mDMEavnPrxYJKwYBBAHaRw8BAQdAY8hDo510mD5V6rCUigAu4swecOh0Od+KFw47
|
||||
KvZDj9O0HFZhaGFwIEVyZW4gPHZhaGFwQGhhY2tlci50cj6ImQQTFgoAQRYhBFfb
|
||||
uyr/HLdU6b/FxUTPd1iIzyiDBQJq+c+vAhsDBQkFo5qABQsJCAcCAiICBhUKCQgL
|
||||
AgQWAgMBAh4HAheAAAoJEETPd1iIzyiDgKQA/A3kmcqADkh8tnOyjirPStErAIWf
|
||||
m7xZaLBl/Ja/g47vAQDcZQZDluXxsrGenGzNFG894mPqQBOMF58uqTuTbe0nC7g4
|
||||
BGr5z68SCisGAQQBl1UBBQEBB0DiV0kmCCfIGpfX6NQdI0BVyWLbQUI4da13Tpq1
|
||||
NNm8WQMBCAeIfgQYFgoAJhYhBFfbuyr/HLdU6b/FxUTPd1iIzyiDBQJq+c+vAhsM
|
||||
BQkFo5qAAAoJEETPd1iIzyiDyfUA/j2hlJudaGT/ma7RL5arNVfYx9OMKyTNdgyJ
|
||||
fVeQaoMWAP9j0H8RYkNa1fYn8jXiRzmsy/dJMjprs3WVx2pF2H0/Bg==
|
||||
=/2Jh
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
7
content/site.conf
Normal file
7
content/site.conf
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# hacker.tr canonical site configuration (validated by Rust htr-guard)
|
||||
researcher_name: Vahap Eren
|
||||
email: vahap@hacker.tr
|
||||
domain: hacker.tr
|
||||
pgp_fingerprint: 57DBBB2AFF1CB754E9BFC5C544CF775888CF2883
|
||||
pgp_public_key_file: pgp-public.asc
|
||||
security_txt_expires: 2027-11-15T00:00:00Z
|
||||
108
deploy/hacker.caddy
Normal file
108
deploy/hacker.caddy
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
# hacker.tr / hack.tr: Caddy site configuration (/etc/caddy/sites/hacker.caddy)
|
||||
# Static site built via Rust security gate (htr-guard) + Go multi-language renderer (htr-site).
|
||||
# Zero PHP, zero backend sockets, zero forms, read-only static files under /srv/hacker-tr/current.
|
||||
|
||||
www.hacker.tr, hack.tr, www.hack.tr {
|
||||
header {
|
||||
Strict-Transport-Security "max-age=63072000; includeSubDomains"
|
||||
-Server
|
||||
}
|
||||
redir https://hacker.tr{uri} permanent
|
||||
}
|
||||
|
||||
hacker.tr {
|
||||
root * /srv/hacker-tr/current
|
||||
encode zstd gzip
|
||||
|
||||
# Only safe read-only HTTP methods allowed (zero input surface)
|
||||
@unsafe_method not method GET HEAD
|
||||
handle @unsafe_method {
|
||||
respond 405
|
||||
}
|
||||
|
||||
header {
|
||||
Content-Security-Policy "default-src 'none'; style-src 'self'; img-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; object-src 'none'; upgrade-insecure-requests"
|
||||
Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-Frame-Options "DENY"
|
||||
Referrer-Policy "no-referrer"
|
||||
Permissions-Policy "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=(), browsing-topics=()"
|
||||
Cross-Origin-Opener-Policy "same-origin"
|
||||
Cross-Origin-Resource-Policy "same-origin"
|
||||
-Server
|
||||
}
|
||||
|
||||
@assets path /assets/* /favicon.svg
|
||||
header @assets Cache-Control "public, max-age=604800"
|
||||
|
||||
@xml path *.xml
|
||||
header @xml Content-Type "application/xml; charset=utf-8"
|
||||
|
||||
@asc path /pgp.asc /.well-known/security.txt /SHA256SUMS.txt
|
||||
header @asc Content-Type "text/plain; charset=utf-8"
|
||||
|
||||
@wkd path /.well-known/openpgpkey/*
|
||||
header @wkd {
|
||||
Content-Type "application/octet-stream"
|
||||
Access-Control-Allow-Origin "*"
|
||||
}
|
||||
|
||||
handle {
|
||||
file_server
|
||||
}
|
||||
|
||||
handle_errors 404 {
|
||||
@err_fr path /fr/*
|
||||
rewrite @err_fr /fr/404.html
|
||||
@err_de path /de/*
|
||||
rewrite @err_de /de/404.html
|
||||
@err_tr path /tr/*
|
||||
rewrite @err_tr /tr/404.html
|
||||
@err_pt path /pt/*
|
||||
rewrite @err_pt /pt/404.html
|
||||
@err_it path /it/*
|
||||
rewrite @err_it /it/404.html
|
||||
@err_ru path /ru/*
|
||||
rewrite @err_ru /ru/404.html
|
||||
@err_zh path /zh/*
|
||||
rewrite @err_zh /zh/404.html
|
||||
@err_es path /es/*
|
||||
rewrite @err_es /es/404.html
|
||||
@err_en not path /fr/* /de/* /tr/* /pt/* /it/* /ru/* /zh/* /es/*
|
||||
rewrite @err_en /404.html
|
||||
file_server
|
||||
}
|
||||
}
|
||||
|
||||
# Read-only public open-source code forge (Forgejo on 127.0.0.1:3030)
|
||||
# Zero public login/admin/push surface: external internet can only browse & git-clone.
|
||||
git.hacker.tr {
|
||||
encode zstd gzip
|
||||
|
||||
header {
|
||||
Strict-Transport-Security "max-age=63072000; includeSubDomains"
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-Frame-Options "DENY"
|
||||
Referrer-Policy "no-referrer"
|
||||
-Server
|
||||
}
|
||||
|
||||
@blocked_auth {
|
||||
path /user/* /admin/* /api/v1/admin/* /*/git-receive-pack
|
||||
}
|
||||
handle @blocked_auth {
|
||||
respond "Read-only public open-source mirror (zero public login surface)." 403
|
||||
}
|
||||
|
||||
@disallowed_method {
|
||||
not method GET HEAD
|
||||
not path /*/git-upload-pack
|
||||
}
|
||||
handle @disallowed_method {
|
||||
respond 405
|
||||
}
|
||||
|
||||
handle {
|
||||
reverse_proxy 127.0.0.1:3030
|
||||
}
|
||||
}
|
||||
65
deploy/rebuild.sh
Executable file
65
deploy/rebuild.sh
Executable file
|
|
@ -0,0 +1,65 @@
|
|||
#!/usr/bin/env bash
|
||||
# hacker.tr atomic build & deploy pipeline (Rust security gate + Go renderer + GPG signing)
|
||||
set -euo pipefail
|
||||
|
||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
SRV_ROOT="/srv/hacker-tr"
|
||||
BUILD_TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$BUILD_TMP"' EXIT
|
||||
|
||||
echo "[1/6] Running Rust security & redaction unit tests (htr-guard)..."
|
||||
cargo test --manifest-path "$REPO_DIR/rust-guard/Cargo.toml" --quiet
|
||||
|
||||
echo "[2/6] Building Rust security gate (release) & Go renderer..."
|
||||
cargo build --release --manifest-path "$REPO_DIR/rust-guard/Cargo.toml" --quiet
|
||||
(cd "$REPO_DIR/go-site" && go build -trimpath -ldflags="-s -w" -o "$BUILD_TMP/htr-site" .)
|
||||
|
||||
GUARD_BIN="$REPO_DIR/rust-guard/target/release/htr-guard"
|
||||
SITE_BIN="$BUILD_TMP/htr-site"
|
||||
|
||||
echo "[3/6] Executing Rust disclosure redaction gate..."
|
||||
"$GUARD_BIN" gate "$REPO_DIR/content" "$BUILD_TMP/bundle.json"
|
||||
|
||||
echo "[4/6] Rendering 9-language static HTML, Atom feeds, and sitemap via Go..."
|
||||
"$SITE_BIN" "$BUILD_TMP/bundle.json" "$BUILD_TMP/dist"
|
||||
|
||||
# Optional: Cleartext-sign /.well-known/security.txt per RFC 9116 §2.3 & export OpenPGP WKD if local GPG keyring exists
|
||||
if [[ -d "/etc/hacker-tr/gnupg" ]]; then
|
||||
echo "[4b/6] Signing /.well-known/security.txt & exporting OpenPGP WKD (vahap@hacker.tr)..."
|
||||
export GNUPGHOME="/etc/hacker-tr/gnupg"
|
||||
gpg --batch --yes --clearsign --local-user "vahap@hacker.tr" \
|
||||
--output "$BUILD_TMP/security.txt.signed" \
|
||||
"$BUILD_TMP/dist/.well-known/security.txt"
|
||||
mv "$BUILD_TMP/security.txt.signed" "$BUILD_TMP/dist/.well-known/security.txt"
|
||||
|
||||
# OpenPGP Web Key Directory (WKD Direct Method) so Thunderbird/ProtonMail/GPG auto-discover vahap@hacker.tr PGP key
|
||||
WKD_DIR="$BUILD_TMP/dist/.well-known/openpgpkey"
|
||||
mkdir -p "$WKD_DIR/hu"
|
||||
: > "$WKD_DIR/policy"
|
||||
gpg --batch --yes --export "vahap@hacker.tr" > "$WKD_DIR/hu/34c6nr91y93w1m4k9n7kxtju6ayonfud"
|
||||
fi
|
||||
|
||||
echo "[5/6] Auditing output HTML & sealing SHA-256 manifest via Rust htr-guard..."
|
||||
"$GUARD_BIN" seal "$BUILD_TMP/dist" "$BUILD_TMP/SHA256SUMS.txt"
|
||||
if [[ -d "/etc/hacker-tr/gnupg" ]]; then
|
||||
export GNUPGHOME="/etc/hacker-tr/gnupg"
|
||||
gpg --batch --yes --clearsign --local-user "vahap@hacker.tr" \
|
||||
--output "$BUILD_TMP/dist/SHA256SUMS.txt" \
|
||||
"$BUILD_TMP/SHA256SUMS.txt"
|
||||
else
|
||||
cp "$BUILD_TMP/SHA256SUMS.txt" "$BUILD_TMP/dist/SHA256SUMS.txt"
|
||||
fi
|
||||
|
||||
echo "[6/6] Publishing atomic release to $SRV_ROOT/current..."
|
||||
TS="$(date -u +%Y%m%d-%H%M%S)"
|
||||
REL_DIR="$SRV_ROOT/releases/$TS"
|
||||
mkdir -p "$SRV_ROOT/releases"
|
||||
cp -r "$BUILD_TMP/dist" "$REL_DIR"
|
||||
chmod -R a+rX,u+w,go-w "$REL_DIR"
|
||||
ln -sfn "$REL_DIR" "$SRV_ROOT/current.tmp"
|
||||
mv -Tf "$SRV_ROOT/current.tmp" "$SRV_ROOT/current"
|
||||
|
||||
# Keep last 5 releases
|
||||
ls -1dt "$SRV_ROOT/releases/"* 2>/dev/null | tail -n +6 | xargs -r rm -rf
|
||||
|
||||
echo "[OK] hacker.tr deployed -> $REL_DIR"
|
||||
3
go-site/go.mod
Normal file
3
go-site/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module hacker.tr/htr-site
|
||||
|
||||
go 1.22
|
||||
1642
go-site/i18n.go
Normal file
1642
go-site/i18n.go
Normal file
File diff suppressed because it is too large
Load diff
680
go-site/main.go
Normal file
680
go-site/main.go
Normal file
|
|
@ -0,0 +1,680 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SanitizedBundle struct {
|
||||
ResearcherName string `json:"researcher_name"`
|
||||
Email string `json:"email"`
|
||||
Domain string `json:"domain"`
|
||||
PGPFingerprintRaw string `json:"pgp_fingerprint_raw"`
|
||||
PGPFingerprintGrouped string `json:"pgp_fingerprint_grouped"`
|
||||
PGPPublicKey string `json:"pgp_public_key"`
|
||||
SecurityTxtExpires string `json:"security_txt_expires"`
|
||||
Advisories []SanitizedAdvisory `json:"advisories"`
|
||||
}
|
||||
|
||||
type SanitizedAdvisory struct {
|
||||
HTRID string `json:"htr_id"`
|
||||
Status string `json:"status"`
|
||||
ReportedDate string `json:"reported_date"`
|
||||
UpdatedDate string `json:"updated_date"`
|
||||
PublishedDate *string `json:"published_date"`
|
||||
CVEID *string `json:"cve_id"`
|
||||
VulnClass map[string]string `json:"vuln_class"`
|
||||
Product *string `json:"product"`
|
||||
AffectedVersions *string `json:"affected_versions"`
|
||||
Severity *string `json:"severity"`
|
||||
CVSS *string `json:"cvss"`
|
||||
SummaryI18N map[string]string `json:"summary_i18n"`
|
||||
TimelineI18N map[string][]string `json:"timeline_i18n"`
|
||||
}
|
||||
|
||||
type AltLink struct {
|
||||
Hreflang string
|
||||
NativeName string
|
||||
Code string
|
||||
Href string
|
||||
Path string
|
||||
Active bool
|
||||
}
|
||||
|
||||
type NavItem struct {
|
||||
Key string
|
||||
Label string
|
||||
Href string
|
||||
Active bool
|
||||
}
|
||||
|
||||
type AdvisoryView struct {
|
||||
HTRID string
|
||||
SlugPath string
|
||||
CanonicalURL string
|
||||
Status string
|
||||
StatusText string
|
||||
IsPublished bool
|
||||
ReportedDate string
|
||||
UpdatedDate string
|
||||
PublishedDate string
|
||||
CVEID string
|
||||
DisplayTitle string
|
||||
VulnClass string
|
||||
Product string
|
||||
AffectedVersions string
|
||||
Severity string
|
||||
CVSS string
|
||||
Summary string
|
||||
Timeline []string
|
||||
}
|
||||
|
||||
type PageData struct {
|
||||
Lang Locale
|
||||
PageKey string // about, advisories, advisory_detail, disclosure, research, services, support, transparency, contact, 404
|
||||
Title string
|
||||
MetaDesc string
|
||||
CanonicalURL string
|
||||
RobotsMeta string
|
||||
AltLinks []AltLink
|
||||
XDefaultURL string
|
||||
NavItems []NavItem
|
||||
JSONLD template.JS
|
||||
FeedPath string
|
||||
CSSPath string
|
||||
EmailOffOpen template.HTML
|
||||
EmailOffClose template.HTML
|
||||
|
||||
ResearcherName string
|
||||
Email string
|
||||
Domain string
|
||||
PGPFprGrouped string
|
||||
PGPFprRaw string
|
||||
PGPPublicKey string
|
||||
|
||||
Advisories []AdvisoryView
|
||||
Advisory *AdvisoryView
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) != 3 {
|
||||
fmt.Fprintf(os.Stderr, "Usage: htr-site <sanitized-bundle.json> <dist-dir>\n")
|
||||
os.Exit(2)
|
||||
}
|
||||
bundlePath := os.Args[1]
|
||||
distDir := os.Args[2]
|
||||
|
||||
raw, err := os.ReadFile(bundlePath)
|
||||
if err != nil {
|
||||
fatalf("read bundle: %v", err)
|
||||
}
|
||||
var bundle SanitizedBundle
|
||||
if err := json.Unmarshal(raw, &bundle); err != nil {
|
||||
fatalf("parse bundle JSON: %v", err)
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(distDir); err != nil {
|
||||
fatalf("clean dist: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(distDir, 0o755); err != nil {
|
||||
fatalf("mkdir dist: %v", err)
|
||||
}
|
||||
|
||||
locales := AllLocales()
|
||||
baseURL := "https://" + bundle.Domain
|
||||
|
||||
// 1. Write content-hashed static stylesheet (busts CDN edge cache automatically on every CSS change)
|
||||
cssDigest := sha256.Sum256([]byte(siteCSS))
|
||||
cssHash := hex.EncodeToString(cssDigest[:])[:12]
|
||||
cssFileName := "style." + cssHash + ".css"
|
||||
cssPublicPath := "/assets/" + cssFileName
|
||||
if err := writeFile(filepath.Join(distDir, "assets", cssFileName), []byte(siteCSS)); err != nil {
|
||||
fatalf("write %s: %v", cssFileName, err)
|
||||
}
|
||||
if err := writeFile(filepath.Join(distDir, "assets", "style.css"), []byte(siteCSS)); err != nil {
|
||||
fatalf("write style.css: %v", err)
|
||||
}
|
||||
emailOffOpen := template.HTML("<!--email_off-->")
|
||||
emailOffClose := template.HTML("<!--/email_off-->")
|
||||
|
||||
// 2. Write SVG favicon
|
||||
if err := writeFile(filepath.Join(distDir, "favicon.svg"), []byte(faviconSVG)); err != nil {
|
||||
fatalf("write favicon.svg: %v", err)
|
||||
}
|
||||
|
||||
// 3. Write PGP public key at /pgp.asc
|
||||
if err := writeFile(filepath.Join(distDir, "pgp.asc"), []byte(bundle.PGPPublicKey)); err != nil {
|
||||
fatalf("write pgp.asc: %v", err)
|
||||
}
|
||||
|
||||
// 4. Write RFC 9116 /.well-known/security.txt
|
||||
secTxt := buildSecurityTxt(&bundle, baseURL)
|
||||
if err := writeFile(filepath.Join(distDir, ".well-known", "security.txt"), []byte(secTxt)); err != nil {
|
||||
fatalf("write security.txt: %v", err)
|
||||
}
|
||||
|
||||
// 5. Write robots.txt
|
||||
robots := fmt.Sprintf("User-agent: *\nAllow: /\n\nSitemap: %s/sitemap.xml\n", baseURL)
|
||||
if err := writeFile(filepath.Join(distDir, "robots.txt"), []byte(robots)); err != nil {
|
||||
fatalf("write robots.txt: %v", err)
|
||||
}
|
||||
|
||||
// 6. Parse master HTML template (context-aware auto-escaping via html/template)
|
||||
tmpl, err := template.New("page").Parse(masterHTMLTemplate)
|
||||
if err != nil {
|
||||
fatalf("compile html/template: %v", err)
|
||||
}
|
||||
|
||||
pageSpecs := []struct {
|
||||
key string
|
||||
relSlug string // "" for root, "advisories/", etc.
|
||||
}{
|
||||
{"about", ""},
|
||||
{"advisories", "advisories/"},
|
||||
{"disclosure", "disclosure/"},
|
||||
{"research", "research/"},
|
||||
{"services", "services/"},
|
||||
{"support", "support/"},
|
||||
{"transparency", "transparency/"},
|
||||
{"contact", "contact/"},
|
||||
}
|
||||
|
||||
var sitemapURLs []sitemapEntry
|
||||
nowDate := time.Now().UTC().Format("2006-01-02")
|
||||
|
||||
for _, loc := range locales {
|
||||
advViews := buildAdvisoryViews(&bundle, loc, baseURL)
|
||||
feedRelPath := loc.Prefix + "/advisories/feed.xml"
|
||||
|
||||
for _, ps := range pageSpecs {
|
||||
altLinks, xDefault := buildAltLinks(locales, baseURL, ps.relSlug, loc.Code)
|
||||
canonPath := pagePath(loc.Prefix, ps.relSlug)
|
||||
canonURL := baseURL + canonPath
|
||||
|
||||
title, desc, breadcrumbName := pageMeta(loc, ps.key)
|
||||
jsonLD, err := buildPageJSONLD(&bundle, loc, ps.key, title, desc, canonURL, breadcrumbName, baseURL)
|
||||
if err != nil {
|
||||
fatalf("build JSON-LD (%s/%s): %v", loc.Code, ps.key, err)
|
||||
}
|
||||
|
||||
pd := PageData{
|
||||
Lang: loc,
|
||||
PageKey: ps.key,
|
||||
Title: title,
|
||||
MetaDesc: desc,
|
||||
CanonicalURL: canonURL,
|
||||
AltLinks: altLinks,
|
||||
XDefaultURL: xDefault,
|
||||
NavItems: buildNav(loc, ps.key),
|
||||
JSONLD: template.JS(jsonLD),
|
||||
FeedPath: feedRelPath,
|
||||
CSSPath: cssPublicPath,
|
||||
EmailOffOpen: emailOffOpen,
|
||||
EmailOffClose: emailOffClose,
|
||||
ResearcherName: bundle.ResearcherName,
|
||||
Email: bundle.Email,
|
||||
Domain: bundle.Domain,
|
||||
PGPFprGrouped: bundle.PGPFingerprintGrouped,
|
||||
PGPFprRaw: bundle.PGPFingerprintRaw,
|
||||
PGPPublicKey: bundle.PGPPublicKey,
|
||||
Advisories: advViews,
|
||||
}
|
||||
|
||||
outHTML, err := renderTemplate(tmpl, &pd)
|
||||
if err != nil {
|
||||
fatalf("render %s (%s): %v", ps.key, loc.Code, err)
|
||||
}
|
||||
|
||||
diskPath := filepath.Join(distDir, filepath.FromSlash(strings.TrimPrefix(canonPath, "/")), "index.html")
|
||||
if err := writeFile(diskPath, outHTML); err != nil {
|
||||
fatalf("write %s: %v", diskPath, err)
|
||||
}
|
||||
|
||||
sitemapURLs = append(sitemapURLs, sitemapEntry{
|
||||
Loc: canonURL,
|
||||
LastMod: nowDate,
|
||||
AltLinks: altLinks,
|
||||
XDefault: xDefault,
|
||||
})
|
||||
}
|
||||
|
||||
// Render individual HTR advisory pages for every language
|
||||
for i := range advViews {
|
||||
av := advViews[i]
|
||||
relSlug := "advisories/" + av.HTRID + "/"
|
||||
altLinks, xDefault := buildAltLinks(locales, baseURL, relSlug, loc.Code)
|
||||
canonPath := pagePath(loc.Prefix, relSlug)
|
||||
canonURL := baseURL + canonPath
|
||||
|
||||
advPageTitle := fmt.Sprintf("%s: %s — %s | %s", av.HTRID, av.DisplayTitle, bundle.ResearcherName, bundle.Domain)
|
||||
advPageDesc := fmt.Sprintf("%s — %s (%s).", av.HTRID, av.DisplayTitle, av.StatusText)
|
||||
jsonLD, err := buildAdvisoryJSONLD(&bundle, loc, &av, advPageTitle, advPageDesc, canonURL, baseURL)
|
||||
if err != nil {
|
||||
fatalf("build advisory JSON-LD: %v", err)
|
||||
}
|
||||
|
||||
pd := PageData{
|
||||
Lang: loc,
|
||||
PageKey: "advisory_detail",
|
||||
Title: advPageTitle,
|
||||
MetaDesc: advPageDesc,
|
||||
CanonicalURL: canonURL,
|
||||
AltLinks: altLinks,
|
||||
XDefaultURL: xDefault,
|
||||
NavItems: buildNav(loc, "advisories"),
|
||||
JSONLD: template.JS(jsonLD),
|
||||
FeedPath: feedRelPath,
|
||||
CSSPath: cssPublicPath,
|
||||
EmailOffOpen: emailOffOpen,
|
||||
EmailOffClose: emailOffClose,
|
||||
ResearcherName: bundle.ResearcherName,
|
||||
Email: bundle.Email,
|
||||
Domain: bundle.Domain,
|
||||
PGPFprGrouped: bundle.PGPFingerprintGrouped,
|
||||
PGPFprRaw: bundle.PGPFingerprintRaw,
|
||||
PGPPublicKey: bundle.PGPPublicKey,
|
||||
Advisory: &av,
|
||||
}
|
||||
|
||||
outHTML, err := renderTemplate(tmpl, &pd)
|
||||
if err != nil {
|
||||
fatalf("render advisory %s (%s): %v", av.HTRID, loc.Code, err)
|
||||
}
|
||||
diskPath := filepath.Join(distDir, filepath.FromSlash(strings.TrimPrefix(canonPath, "/")), "index.html")
|
||||
if err := writeFile(diskPath, outHTML); err != nil {
|
||||
fatalf("write %s: %v", diskPath, err)
|
||||
}
|
||||
|
||||
sitemapURLs = append(sitemapURLs, sitemapEntry{
|
||||
Loc: canonURL,
|
||||
LastMod: av.UpdatedDate,
|
||||
AltLinks: altLinks,
|
||||
XDefault: xDefault,
|
||||
})
|
||||
}
|
||||
|
||||
// Render localized Atom XML feed (/advisories/feed.xml and /<lang>/advisories/feed.xml)
|
||||
atomXML := buildAtomFeed(&bundle, loc, advViews, baseURL, feedRelPath)
|
||||
feedDisk := filepath.Join(distDir, filepath.FromSlash(strings.TrimPrefix(feedRelPath, "/")))
|
||||
if err := writeFile(feedDisk, []byte(atomXML)); err != nil {
|
||||
fatalf("write feed %s: %v", feedDisk, err)
|
||||
}
|
||||
|
||||
// Render localized 404.html
|
||||
notFoundPath := loc.Prefix + "/404.html"
|
||||
if loc.Prefix == "" {
|
||||
notFoundPath = "/404.html"
|
||||
}
|
||||
pd404 := PageData{
|
||||
Lang: loc,
|
||||
PageKey: "404",
|
||||
Title: "404 — " + bundle.Domain,
|
||||
MetaDesc: "404 Not Found",
|
||||
CanonicalURL: baseURL + pagePath(loc.Prefix, ""),
|
||||
RobotsMeta: "noindex",
|
||||
NavItems: buildNav(loc, ""),
|
||||
FeedPath: feedRelPath,
|
||||
CSSPath: cssPublicPath,
|
||||
EmailOffOpen: emailOffOpen,
|
||||
EmailOffClose: emailOffClose,
|
||||
ResearcherName: bundle.ResearcherName,
|
||||
Email: bundle.Email,
|
||||
Domain: bundle.Domain,
|
||||
PGPFprGrouped: bundle.PGPFingerprintGrouped,
|
||||
}
|
||||
html404, err := renderTemplate(tmpl, &pd404)
|
||||
if err != nil {
|
||||
fatalf("render 404 (%s): %v", loc.Code, err)
|
||||
}
|
||||
if err := writeFile(filepath.Join(distDir, filepath.FromSlash(strings.TrimPrefix(notFoundPath, "/"))), html404); err != nil {
|
||||
fatalf("write 404: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Also alias /feed.xml -> English Atom feed for convenience
|
||||
enViews := buildAdvisoryViews(&bundle, locales[0], baseURL)
|
||||
rootAtom := buildAtomFeed(&bundle, locales[0], enViews, baseURL, "/feed.xml")
|
||||
if err := writeFile(filepath.Join(distDir, "feed.xml"), []byte(rootAtom)); err != nil {
|
||||
fatalf("write /feed.xml: %v", err)
|
||||
}
|
||||
|
||||
// 7. Write multi-language hreflang sitemap.xml
|
||||
sitemapBytes := buildSitemapXML(sitemapURLs)
|
||||
if err := writeFile(filepath.Join(distDir, "sitemap.xml"), []byte(sitemapBytes)); err != nil {
|
||||
fatalf("write sitemap.xml: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("[htr-site] Rendered %d localized pages across %d languages -> %s\n", len(sitemapURLs), len(locales), distDir)
|
||||
}
|
||||
|
||||
func pagePath(prefix, relSlug string) string {
|
||||
if prefix == "" {
|
||||
return "/" + relSlug
|
||||
}
|
||||
if relSlug == "" {
|
||||
return prefix + "/"
|
||||
}
|
||||
return prefix + "/" + relSlug
|
||||
}
|
||||
|
||||
func buildAltLinks(locales []Locale, baseURL, relSlug, activeCode string) ([]AltLink, string) {
|
||||
out := make([]AltLink, 0, len(locales))
|
||||
xDefault := baseURL + "/" + relSlug
|
||||
for _, l := range locales {
|
||||
p := pagePath(l.Prefix, relSlug)
|
||||
out = append(out, AltLink{
|
||||
Hreflang: l.Hreflang,
|
||||
NativeName: l.NativeName,
|
||||
Code: strings.ToUpper(l.Code),
|
||||
Href: baseURL + p,
|
||||
Path: p,
|
||||
Active: l.Code == activeCode,
|
||||
})
|
||||
}
|
||||
return out, xDefault
|
||||
}
|
||||
|
||||
func buildNav(loc Locale, activeKey string) []NavItem {
|
||||
p := func(slug string) string { return pagePath(loc.Prefix, slug) }
|
||||
return []NavItem{
|
||||
{Key: "about", Label: loc.NavAbout, Href: p(""), Active: activeKey == "about"},
|
||||
{Key: "advisories", Label: loc.NavAdvisories, Href: p("advisories/"), Active: activeKey == "advisories"},
|
||||
{Key: "disclosure", Label: loc.NavDisclosure, Href: p("disclosure/"), Active: activeKey == "disclosure"},
|
||||
{Key: "research", Label: loc.NavResearch, Href: p("research/"), Active: activeKey == "research"},
|
||||
{Key: "services", Label: loc.NavServices, Href: p("services/"), Active: activeKey == "services"},
|
||||
{Key: "support", Label: loc.NavSupport, Href: p("support/"), Active: activeKey == "support"},
|
||||
{Key: "transparency", Label: loc.NavTransparency, Href: p("transparency/"), Active: activeKey == "transparency"},
|
||||
{Key: "contact", Label: loc.NavContact, Href: p("contact/"), Active: activeKey == "contact"},
|
||||
}
|
||||
}
|
||||
|
||||
func pageMeta(loc Locale, key string) (title, desc, crumb string) {
|
||||
switch key {
|
||||
case "about":
|
||||
return loc.AboutTitle, loc.AboutMetaDesc, loc.NavAbout
|
||||
case "advisories":
|
||||
return loc.AdvTitle, loc.AdvMetaDesc, loc.NavAdvisories
|
||||
case "disclosure":
|
||||
return loc.DiscTitle, loc.DiscMetaDesc, loc.NavDisclosure
|
||||
case "research":
|
||||
return loc.ResTitle, loc.ResMetaDesc, loc.NavResearch
|
||||
case "services":
|
||||
return loc.SrvTitle, loc.SrvMetaDesc, loc.NavServices
|
||||
case "support":
|
||||
return loc.SupTitle, loc.SupMetaDesc, loc.NavSupport
|
||||
case "transparency":
|
||||
return loc.TrnTitle, loc.TrnMetaDesc, loc.NavTransparency
|
||||
case "contact":
|
||||
return loc.CntTitle, loc.CntMetaDesc, loc.NavContact
|
||||
default:
|
||||
return loc.AboutTitle, loc.AboutMetaDesc, loc.NavAbout
|
||||
}
|
||||
}
|
||||
|
||||
func buildAdvisoryViews(bundle *SanitizedBundle, loc Locale, baseURL string) []AdvisoryView {
|
||||
views := make([]AdvisoryView, 0, len(bundle.Advisories))
|
||||
for _, a := range bundle.Advisories {
|
||||
vClass := a.VulnClass[loc.Code]
|
||||
if vClass == "" {
|
||||
vClass = a.VulnClass["en"]
|
||||
}
|
||||
statusText := loc.AdvStatusReported
|
||||
switch a.Status {
|
||||
case "reported":
|
||||
statusText = loc.AdvStatusReported
|
||||
case "cve_requested":
|
||||
statusText = loc.AdvStatusCVEReq
|
||||
case "cve_reserved":
|
||||
statusText = loc.AdvStatusCVERes
|
||||
case "published":
|
||||
statusText = loc.AdvStatusPublished
|
||||
}
|
||||
|
||||
cve := "—"
|
||||
if a.CVEID != nil && *a.CVEID != "" {
|
||||
cve = *a.CVEID
|
||||
}
|
||||
|
||||
slugPath := pagePath(loc.Prefix, "advisories/"+a.HTRID+"/")
|
||||
displayTitle := vClass
|
||||
var prod, vers, sev, cvss, pubDate, summary string
|
||||
isPub := a.Status == "published"
|
||||
if isPub {
|
||||
if a.Product != nil {
|
||||
prod = *a.Product
|
||||
displayTitle = prod + " — " + vClass
|
||||
}
|
||||
if a.AffectedVersions != nil {
|
||||
vers = *a.AffectedVersions
|
||||
}
|
||||
if a.Severity != nil {
|
||||
sev = *a.Severity
|
||||
}
|
||||
if a.CVSS != nil {
|
||||
cvss = *a.CVSS
|
||||
}
|
||||
if a.PublishedDate != nil {
|
||||
pubDate = *a.PublishedDate
|
||||
}
|
||||
summary = a.SummaryI18N[loc.Code]
|
||||
if summary == "" {
|
||||
summary = a.SummaryI18N["en"]
|
||||
}
|
||||
}
|
||||
|
||||
tl := a.TimelineI18N[loc.Code]
|
||||
if len(tl) == 0 {
|
||||
tl = a.TimelineI18N["en"]
|
||||
}
|
||||
|
||||
views = append(views, AdvisoryView{
|
||||
HTRID: a.HTRID,
|
||||
SlugPath: slugPath,
|
||||
CanonicalURL: baseURL + slugPath,
|
||||
Status: a.Status,
|
||||
StatusText: statusText,
|
||||
IsPublished: isPub,
|
||||
ReportedDate: a.ReportedDate,
|
||||
UpdatedDate: a.UpdatedDate,
|
||||
PublishedDate: pubDate,
|
||||
CVEID: cve,
|
||||
DisplayTitle: displayTitle,
|
||||
VulnClass: vClass,
|
||||
Product: prod,
|
||||
AffectedVersions: vers,
|
||||
Severity: sev,
|
||||
CVSS: cvss,
|
||||
Summary: summary,
|
||||
Timeline: tl,
|
||||
})
|
||||
}
|
||||
return views
|
||||
}
|
||||
|
||||
// buildPageJSONLD constructs valid, comment-free Google Rich Results JSON-LD (ProfilePage on home, BreadcrumbList on subpages).
|
||||
func buildPageJSONLD(bundle *SanitizedBundle, loc Locale, pageKey, title, desc, canonURL, crumbName, baseURL string) (string, error) {
|
||||
homeURL := baseURL + pagePath(loc.Prefix, "")
|
||||
|
||||
personObj := map[string]any{
|
||||
"@type": "Person",
|
||||
"@id": baseURL + "/#vahap-eren",
|
||||
"name": bundle.ResearcherName,
|
||||
"jobTitle": loc.AboutRoleVal,
|
||||
"url": baseURL + "/",
|
||||
"email": "mailto:" + bundle.Email,
|
||||
}
|
||||
|
||||
if pageKey == "about" {
|
||||
doc := map[string]any{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "ProfilePage",
|
||||
"name": title,
|
||||
"description": desc,
|
||||
"url": canonURL,
|
||||
"inLanguage": loc.Hreflang,
|
||||
"mainEntity": personObj,
|
||||
}
|
||||
b, err := json.Marshal(doc)
|
||||
return string(b), err
|
||||
}
|
||||
|
||||
doc := map[string]any{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
"itemListElement": []map[string]any{
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 1,
|
||||
"name": loc.NavAbout,
|
||||
"item": homeURL,
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 2,
|
||||
"name": crumbName,
|
||||
"item": canonURL,
|
||||
},
|
||||
},
|
||||
}
|
||||
b, err := json.Marshal(doc)
|
||||
return string(b), err
|
||||
}
|
||||
|
||||
func buildAdvisoryJSONLD(bundle *SanitizedBundle, loc Locale, av *AdvisoryView, title, desc, canonURL, baseURL string) (string, error) {
|
||||
homeURL := baseURL + pagePath(loc.Prefix, "")
|
||||
advIndexURL := baseURL + pagePath(loc.Prefix, "advisories/")
|
||||
doc := map[string]any{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
"itemListElement": []map[string]any{
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 1,
|
||||
"name": loc.NavAbout,
|
||||
"item": homeURL,
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 2,
|
||||
"name": loc.NavAdvisories,
|
||||
"item": advIndexURL,
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 3,
|
||||
"name": av.HTRID,
|
||||
"item": canonURL,
|
||||
},
|
||||
},
|
||||
}
|
||||
_ = title
|
||||
_ = desc
|
||||
_ = bundle
|
||||
b, err := json.Marshal(doc)
|
||||
return string(b), err
|
||||
}
|
||||
|
||||
func buildSecurityTxt(b *SanitizedBundle, baseURL string) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("Contact: mailto:" + b.Email + "\n")
|
||||
sb.WriteString("Encryption: " + baseURL + "/pgp.asc\n")
|
||||
sb.WriteString("Preferred-Languages: en, tr, fr, de, es, pt, it, ru, zh\n")
|
||||
sb.WriteString("Canonical: " + baseURL + "/.well-known/security.txt\n")
|
||||
sb.WriteString("Policy: " + baseURL + "/disclosure/\n")
|
||||
sb.WriteString("Expires: " + b.SecurityTxtExpires + "\n")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
type sitemapEntry struct {
|
||||
Loc string
|
||||
LastMod string
|
||||
AltLinks []AltLink
|
||||
XDefault string
|
||||
}
|
||||
|
||||
func buildSitemapXML(entries []sitemapEntry) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(`<?xml version="1.0" encoding="UTF-8"?>` + "\n")
|
||||
sb.WriteString(`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">` + "\n")
|
||||
for _, e := range entries {
|
||||
sb.WriteString(" <url>\n")
|
||||
sb.WriteString(" <loc>" + xmlEscape(e.Loc) + "</loc>\n")
|
||||
sb.WriteString(" <lastmod>" + xmlEscape(e.LastMod) + "</lastmod>\n")
|
||||
for _, alt := range e.AltLinks {
|
||||
sb.WriteString(fmt.Sprintf(` <xhtml:link rel="alternate" hreflang="%s" href="%s"/>`+"\n",
|
||||
xmlEscape(alt.Hreflang), xmlEscape(alt.Href)))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(` <xhtml:link rel="alternate" hreflang="x-default" href="%s"/>`+"\n",
|
||||
xmlEscape(e.XDefault)))
|
||||
sb.WriteString(" </url>\n")
|
||||
}
|
||||
sb.WriteString("</urlset>\n")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func buildAtomFeed(bundle *SanitizedBundle, loc Locale, views []AdvisoryView, baseURL, feedRelPath string) string {
|
||||
updated := time.Now().UTC().Format(time.RFC3339)
|
||||
if len(views) > 0 {
|
||||
updated = views[0].UpdatedDate + "T00:00:00Z"
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString(`<?xml version="1.0" encoding="utf-8"?>` + "\n")
|
||||
sb.WriteString(fmt.Sprintf(`<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="%s">`+"\n", xmlEscape(loc.Hreflang)))
|
||||
sb.WriteString(" <title>" + xmlEscape(loc.AdvTitle) + "</title>\n")
|
||||
sb.WriteString(fmt.Sprintf(` <link href="%s" rel="self" type="application/atom+xml"/>`+"\n", xmlEscape(baseURL+feedRelPath)))
|
||||
sb.WriteString(fmt.Sprintf(` <link href="%s" rel="alternate" type="text/html"/>`+"\n", xmlEscape(baseURL+pagePath(loc.Prefix, "advisories/"))))
|
||||
sb.WriteString(" <id>" + xmlEscape(baseURL+pagePath(loc.Prefix, "advisories/")) + "</id>\n")
|
||||
sb.WriteString(" <updated>" + xmlEscape(updated) + "</updated>\n")
|
||||
sb.WriteString(" <author><name>" + xmlEscape(bundle.ResearcherName) + "</name><email>" + xmlEscape(bundle.Email) + "</email></author>\n")
|
||||
|
||||
for _, v := range views {
|
||||
sb.WriteString(" <entry>\n")
|
||||
sb.WriteString(" <title>" + xmlEscape(v.HTRID+": "+v.DisplayTitle) + "</title>\n")
|
||||
sb.WriteString(fmt.Sprintf(` <link href="%s" rel="alternate" type="text/html"/>`+"\n", xmlEscape(v.CanonicalURL)))
|
||||
sb.WriteString(" <id>" + xmlEscape(v.CanonicalURL) + "</id>\n")
|
||||
sb.WriteString(" <updated>" + xmlEscape(v.UpdatedDate+"T00:00:00Z") + "</updated>\n")
|
||||
summary := v.StatusText
|
||||
if v.IsPublished && v.Summary != "" {
|
||||
summary = v.Summary
|
||||
}
|
||||
sb.WriteString(" <summary>" + xmlEscape(summary) + "</summary>\n")
|
||||
sb.WriteString(" </entry>\n")
|
||||
}
|
||||
sb.WriteString("</feed>\n")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func xmlEscape(s string) string {
|
||||
var buf bytes.Buffer
|
||||
_ = xml.EscapeText(&buf, []byte(s))
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func renderTemplate(tmpl *template.Template, data *PageData) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func writeFile(path string, content []byte) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, content, 0o644)
|
||||
}
|
||||
|
||||
func fatalf(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, "[htr-site ERROR] "+format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
1993
go-site/templates.go
Normal file
1993
go-site/templates.go
Normal file
File diff suppressed because it is too large
Load diff
7
rust-guard/Cargo.lock
generated
Normal file
7
rust-guard/Cargo.lock
generated
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
|
||||
[[package]]
|
||||
name = "htr-guard"
|
||||
version = "0.1.0"
|
||||
12
rust-guard/Cargo.toml
Normal file
12
rust-guard/Cargo.toml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
[package]
|
||||
name = "htr-guard"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Memory-safe security & disclosure redaction gate for hacker.tr"
|
||||
license = "MIT"
|
||||
homepage = "https://hacker.tr"
|
||||
repository = "https://git.hacker.tr/vahap/htr-guard"
|
||||
|
||||
[[bin]]
|
||||
name = "htr-guard"
|
||||
path = "src/main.rs"
|
||||
964
rust-guard/src/main.rs
Normal file
964
rust-guard/src/main.rs
Normal file
|
|
@ -0,0 +1,964 @@
|
|||
//! htr-guard: Memory-safe security, disclosure redaction gate, and SHA-256 integrity
|
||||
//! verifier for hacker.tr (Vahap Eren <vahap@hacker.tr>).
|
||||
//!
|
||||
//! Zero external crates (pure Rust `std`, `#![forbid(unsafe_code)]`) so the supply-chain
|
||||
//! attack surface is literally zero.
|
||||
//!
|
||||
//! Modes:
|
||||
//! 1. `htr-guard gate <content-dir> <out-bundle.json>`
|
||||
//! - Parses advisory `.md` files with strict front-matter validation.
|
||||
//! - Enforces staged disclosure redaction (`reported`, `cve_requested`, `cve_reserved`, `published`):
|
||||
//! * Never emits `vendor`, `product`, `affected_versions`, `cvss`, `severity`, `timeline`,
|
||||
//! or technical body unless `status == published`.
|
||||
//! * Rejects (fail-closed) any unassigned CVE ID or forbidden claim ("CVE owner", "CVE sahibi", etc.)
|
||||
//! in public fields before assignment.
|
||||
//! * Validates `HTR-YYYY-NNN` identifier format, ISO-8601 dates, and PGP fingerprint format.
|
||||
//! - Emits deterministic sanitized JSON for the Go multi-language site renderer (`htr-site`).
|
||||
//!
|
||||
//! 2. `htr-guard seal <dist-dir> <manifest-path>`
|
||||
//! - Audits every generated HTML/XML/TXT/ASC file in `<dist-dir>` for leaked internal markers,
|
||||
//! forbidden external scripts/fonts, or malformed security.txt fields.
|
||||
//! - Computes SHA-256 digests over every output file (FIPS 180-4 pure-Rust implementation)
|
||||
//! and writes a deterministic build integrity manifest.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process;
|
||||
|
||||
const SUPPORTED_LANGS: [&str; 9] = ["en", "fr", "de", "tr", "pt", "it", "ru", "zh", "es"];
|
||||
|
||||
const FORBIDDEN_CLAIMS: [&str; 6] = [
|
||||
"cve owner",
|
||||
"cve sahibi",
|
||||
"zero-day for sale",
|
||||
"0day for sale",
|
||||
"zero-day talebi",
|
||||
"__htr_redacted__",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum DisclosureStatus {
|
||||
Reported,
|
||||
CveRequested,
|
||||
CveReserved,
|
||||
Published,
|
||||
}
|
||||
|
||||
impl DisclosureStatus {
|
||||
fn parse(raw: &str) -> Result<Self, String> {
|
||||
match raw.trim() {
|
||||
"reported" => Ok(Self::Reported),
|
||||
"cve_requested" => Ok(Self::CveRequested),
|
||||
"cve_reserved" => Ok(Self::CveReserved),
|
||||
"published" => Ok(Self::Published),
|
||||
other => Err(format!(
|
||||
"invalid disclosure status '{}'; expected reported | cve_requested | cve_reserved | published",
|
||||
other
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Reported => "reported",
|
||||
Self::CveRequested => "cve_requested",
|
||||
Self::CveReserved => "cve_reserved",
|
||||
Self::Published => "published",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SanitizedAdvisory {
|
||||
htr_id: String,
|
||||
status: DisclosureStatus,
|
||||
reported_date: String,
|
||||
updated_date: String,
|
||||
published_date: Option<String>,
|
||||
cve_id: Option<String>,
|
||||
vuln_class: BTreeMap<String, String>,
|
||||
// Fields below are strictly `None` / empty unless `status == DisclosureStatus::Published`
|
||||
product: Option<String>,
|
||||
affected_versions: Option<String>,
|
||||
severity: Option<String>,
|
||||
cvss: Option<String>,
|
||||
summary_i18n: BTreeMap<String, String>,
|
||||
timeline_i18n: BTreeMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
if args.len() < 2 {
|
||||
eprintln!("Usage:\n htr-guard gate <content-dir> <out-bundle.json>\n htr-guard seal <dist-dir> <manifest-out>");
|
||||
process::exit(2);
|
||||
}
|
||||
|
||||
let result = match args[1].as_str() {
|
||||
"gate" => {
|
||||
if args.len() != 4 {
|
||||
Err("Usage: htr-guard gate <content-dir> <out-bundle.json>".to_string())
|
||||
} else {
|
||||
run_gate(Path::new(&args[2]), Path::new(&args[3]))
|
||||
}
|
||||
}
|
||||
"seal" => {
|
||||
if args.len() != 4 {
|
||||
Err("Usage: htr-guard seal <dist-dir> <manifest-out>".to_string())
|
||||
} else {
|
||||
run_seal(Path::new(&args[2]), Path::new(&args[3]))
|
||||
}
|
||||
}
|
||||
other => Err(format!("Unknown subcommand '{}'", other)),
|
||||
};
|
||||
|
||||
if let Err(err) = result {
|
||||
eprintln!("[htr-guard SECURITY ABORT] {}", err);
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn run_gate(content_dir: &Path, out_path: &Path) -> Result<(), String> {
|
||||
let meta_path = content_dir.join("site.conf");
|
||||
let site_meta = parse_kv_file(&meta_path)
|
||||
.map_err(|e| format!("failed to read {}: {}", meta_path.display(), e))?;
|
||||
|
||||
let researcher_name = require_field(&site_meta, "researcher_name")?;
|
||||
let email = require_field(&site_meta, "email")?;
|
||||
let domain = require_field(&site_meta, "domain")?;
|
||||
let pgp_fpr = require_field(&site_meta, "pgp_fingerprint")?;
|
||||
let pgp_key_file = require_field(&site_meta, "pgp_public_key_file")?;
|
||||
let security_expires = require_field(&site_meta, "security_txt_expires")?;
|
||||
|
||||
validate_email(&email)?;
|
||||
validate_fingerprint(&pgp_fpr)?;
|
||||
validate_iso_datetime(&security_expires)?;
|
||||
|
||||
let pgp_path = content_dir.join(&pgp_key_file);
|
||||
let pgp_asc = fs::read_to_string(&pgp_path)
|
||||
.map_err(|e| format!("failed to read PGP public key {}: {}", pgp_path.display(), e))?;
|
||||
if !pgp_asc.contains("-----BEGIN PGP PUBLIC KEY BLOCK-----")
|
||||
|| !pgp_asc.contains("-----END PGP PUBLIC KEY BLOCK-----")
|
||||
{
|
||||
return Err("PGP public key file is missing valid ASCII armor headers".to_string());
|
||||
}
|
||||
|
||||
let adv_dir = content_dir.join("advisories");
|
||||
let mut advisories = Vec::new();
|
||||
if adv_dir.exists() {
|
||||
let mut entries: Vec<PathBuf> = fs::read_dir(&adv_dir)
|
||||
.map_err(|e| format!("failed to list {}: {}", adv_dir.display(), e))?
|
||||
.filter_map(|r| r.ok().map(|e| e.path()))
|
||||
.filter(|p| {
|
||||
p.extension().and_then(|s| s.to_str()) == Some("md")
|
||||
&& p.file_name().and_then(|s| s.to_str()) != Some("README.md")
|
||||
})
|
||||
.collect();
|
||||
entries.sort();
|
||||
|
||||
for path in entries {
|
||||
let adv = parse_and_redact_advisory(&path)?;
|
||||
advisories.push(adv);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort advisories newest HTR ID first
|
||||
advisories.sort_by(|a, b| b.htr_id.cmp(&a.htr_id));
|
||||
|
||||
// Check duplicate HTR IDs
|
||||
for w in advisories.windows(2) {
|
||||
if w[0].htr_id == w[1].htr_id {
|
||||
return Err(format!("duplicate advisory ID detected: {}", w[0].htr_id));
|
||||
}
|
||||
}
|
||||
|
||||
let json = serialize_bundle(
|
||||
&researcher_name,
|
||||
&email,
|
||||
&domain,
|
||||
&pgp_fpr,
|
||||
&pgp_asc,
|
||||
&security_expires,
|
||||
&advisories,
|
||||
);
|
||||
|
||||
if let Some(parent) = out_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| format!("mkdir failed: {}", e))?;
|
||||
}
|
||||
fs::write(out_path, json.as_bytes())
|
||||
.map_err(|e| format!("failed to write {}: {}", out_path.display(), e))?;
|
||||
|
||||
println!(
|
||||
"[htr-guard] Gate passed: {} advisories verified & sanitized -> {}",
|
||||
advisories.len(),
|
||||
out_path.display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_and_redact_advisory(path: &Path) -> Result<SanitizedAdvisory, String> {
|
||||
let raw = fs::read_to_string(path)
|
||||
.map_err(|e| format!("failed reading {}: {}", path.display(), e))?;
|
||||
let normalized = raw.replace("\r\n", "\n");
|
||||
|
||||
if !normalized.starts_with("---\n") {
|
||||
return Err(format!("{}: missing opening '---' front-matter delimiter", path.display()));
|
||||
}
|
||||
let rest = &normalized[4..];
|
||||
let end_idx = rest
|
||||
.find("\n---\n")
|
||||
.ok_or_else(|| format!("{}: missing closing '---' front-matter delimiter", path.display()))?;
|
||||
|
||||
let fm_text = &rest[..end_idx];
|
||||
let body_text = rest[end_idx + 5..].trim();
|
||||
|
||||
let fm = parse_kv_str(fm_text)
|
||||
.map_err(|e| format!("{}: front-matter error: {}", path.display(), e))?;
|
||||
|
||||
let htr_id = require_field(&fm, "id")?;
|
||||
validate_htr_id(&htr_id).map_err(|e| format!("{}: {}", path.display(), e))?;
|
||||
|
||||
let status_str = require_field(&fm, "status")?;
|
||||
let status = DisclosureStatus::parse(&status_str)
|
||||
.map_err(|e| format!("{}: {}", path.display(), e))?;
|
||||
|
||||
let reported_date = require_field(&fm, "reported_date")?;
|
||||
validate_iso_date(&reported_date)
|
||||
.map_err(|e| format!("{} (reported_date): {}", path.display(), e))?;
|
||||
|
||||
let updated_date = fm
|
||||
.get("updated_date")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| reported_date.clone());
|
||||
validate_iso_date(&updated_date)
|
||||
.map_err(|e| format!("{} (updated_date): {}", path.display(), e))?;
|
||||
|
||||
// Collect localized vulnerability class (generic class, safe to show in all stages)
|
||||
let mut vuln_class = BTreeMap::new();
|
||||
let default_class = require_field(&fm, "class_en")
|
||||
.map_err(|e| format!("{}: {}", path.display(), e))?;
|
||||
scan_public_text(&default_class, status, fm.get("internal_vendor"), fm.get("internal_product"))
|
||||
.map_err(|e| format!("{} (class_en): {}", path.display(), e))?;
|
||||
|
||||
for lang in SUPPORTED_LANGS {
|
||||
let key = format!("class_{}", lang);
|
||||
let val = fm.get(&key).cloned().unwrap_or_else(|| default_class.clone());
|
||||
scan_public_text(&val, status, fm.get("internal_vendor"), fm.get("internal_product"))
|
||||
.map_err(|e| format!("{} ({}): {}", path.display(), key, e))?;
|
||||
vuln_class.insert(lang.to_string(), val);
|
||||
}
|
||||
|
||||
// CVE validation according to stage
|
||||
let raw_cve = fm.get("cve_id").map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
|
||||
let cve_id = match status {
|
||||
DisclosureStatus::Reported | DisclosureStatus::CveRequested => {
|
||||
// Even if present in private notes, NEVER expose or allow CVE before assignment
|
||||
if raw_cve.is_some() {
|
||||
return Err(format!(
|
||||
"{}: status is '{}' but 'cve_id' is set; unassigned CVE numbers are strictly prohibited",
|
||||
path.display(),
|
||||
status.as_str()
|
||||
));
|
||||
}
|
||||
None
|
||||
}
|
||||
DisclosureStatus::CveReserved => {
|
||||
let cve = raw_cve.ok_or_else(|| {
|
||||
format!("{}: status is 'cve_reserved' so a valid assigned 'cve_id' is required", path.display())
|
||||
})?;
|
||||
validate_cve_id(&cve).map_err(|e| format!("{}: {}", path.display(), e))?;
|
||||
Some(cve)
|
||||
}
|
||||
DisclosureStatus::Published => {
|
||||
if let Some(cve) = raw_cve {
|
||||
validate_cve_id(&cve).map_err(|e| format!("{}: {}", path.display(), e))?;
|
||||
Some(cve)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// REDACTION GATE: Unless `status == Published`, strip all product, version, CVSS, timeline, and body!
|
||||
if status != DisclosureStatus::Published {
|
||||
return Ok(SanitizedAdvisory {
|
||||
htr_id,
|
||||
status,
|
||||
reported_date,
|
||||
updated_date,
|
||||
published_date: None,
|
||||
cve_id,
|
||||
vuln_class,
|
||||
product: None,
|
||||
affected_versions: None,
|
||||
severity: None,
|
||||
cvss: None,
|
||||
summary_i18n: BTreeMap::new(),
|
||||
timeline_i18n: BTreeMap::new(),
|
||||
});
|
||||
}
|
||||
|
||||
// Published advisory: validate and populate full public fields
|
||||
let published_date = require_field(&fm, "published_date")
|
||||
.map_err(|e| format!("{} (published advisory): {}", path.display(), e))?;
|
||||
validate_iso_date(&published_date)
|
||||
.map_err(|e| format!("{} (published_date): {}", path.display(), e))?;
|
||||
|
||||
let product = require_field(&fm, "product")
|
||||
.map_err(|e| format!("{} (published advisory): {}", path.display(), e))?;
|
||||
let affected_versions = require_field(&fm, "affected_versions")
|
||||
.map_err(|e| format!("{} (published advisory): {}", path.display(), e))?;
|
||||
let severity = require_field(&fm, "severity")
|
||||
.map_err(|e| format!("{} (published advisory): {}", path.display(), e))?;
|
||||
let cvss = fm.get("cvss").cloned().filter(|s| !s.is_empty());
|
||||
|
||||
let mut summary_i18n = BTreeMap::new();
|
||||
let default_summary = if !body_text.is_empty() {
|
||||
body_text.to_string()
|
||||
} else {
|
||||
require_field(&fm, "summary_en")
|
||||
.map_err(|e| format!("{} (published advisory): {}", path.display(), e))?
|
||||
};
|
||||
|
||||
for lang in SUPPORTED_LANGS {
|
||||
let key = format!("summary_{}", lang);
|
||||
let text = fm.get(&key).cloned().unwrap_or_else(|| default_summary.clone());
|
||||
check_forbidden_claims(&text)
|
||||
.map_err(|e| format!("{} ({}): {}", path.display(), key, e))?;
|
||||
summary_i18n.insert(lang.to_string(), text);
|
||||
}
|
||||
|
||||
let mut timeline_i18n = BTreeMap::new();
|
||||
let default_tl = fm.get("timeline_en").cloned().unwrap_or_default();
|
||||
for lang in SUPPORTED_LANGS {
|
||||
let key = format!("timeline_{}", lang);
|
||||
let raw_tl = fm.get(&key).cloned().unwrap_or_else(|| default_tl.clone());
|
||||
let items: Vec<String> = raw_tl
|
||||
.split('|')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
timeline_i18n.insert(lang.to_string(), items);
|
||||
}
|
||||
|
||||
Ok(SanitizedAdvisory {
|
||||
htr_id,
|
||||
status,
|
||||
reported_date,
|
||||
updated_date,
|
||||
published_date: Some(published_date),
|
||||
cve_id,
|
||||
vuln_class,
|
||||
product: Some(product),
|
||||
affected_versions: Some(affected_versions),
|
||||
severity: Some(severity),
|
||||
cvss,
|
||||
summary_i18n,
|
||||
timeline_i18n,
|
||||
})
|
||||
}
|
||||
|
||||
fn scan_public_text(
|
||||
text: &str,
|
||||
status: DisclosureStatus,
|
||||
internal_vendor: Option<&String>,
|
||||
internal_product: Option<&String>,
|
||||
) -> Result<(), String> {
|
||||
check_forbidden_claims(text)?;
|
||||
let lower = text.to_lowercase();
|
||||
|
||||
// Prevent accidental CVE string in vulnerability class field
|
||||
if lower.contains("cve-") {
|
||||
return Err("public class field must not contain a CVE identifier".to_string());
|
||||
}
|
||||
|
||||
// If unpublished and private vendor/product names are recorded in front-matter,
|
||||
// verify the public vulnerability class does not accidentally leak them!
|
||||
if status != DisclosureStatus::Published {
|
||||
if let Some(vendor) = internal_vendor {
|
||||
let v = vendor.trim().to_lowercase();
|
||||
if v.len() >= 3 && lower.contains(&v) {
|
||||
return Err(format!(
|
||||
"redaction violation: public vulnerability class leaks internal_vendor '{}'",
|
||||
vendor
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(prod) = internal_product {
|
||||
let p = prod.trim().to_lowercase();
|
||||
if p.len() >= 3 && lower.contains(&p) {
|
||||
return Err(format!(
|
||||
"redaction violation: public vulnerability class leaks internal_product '{}'",
|
||||
prod
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_forbidden_claims(text: &str) -> Result<(), String> {
|
||||
let lower = text.to_lowercase();
|
||||
for claim in FORBIDDEN_CLAIMS {
|
||||
if lower.contains(claim) {
|
||||
return Err(format!("policy violation: forbidden phrase '{}' detected", claim));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_seal(dist_dir: &Path, manifest_out: &Path) -> Result<(), String> {
|
||||
if !dist_dir.exists() {
|
||||
return Err(format!("dist directory does not exist: {}", dist_dir.display()));
|
||||
}
|
||||
|
||||
let sec_txt = dist_dir.join(".well-known").join("security.txt");
|
||||
if !sec_txt.exists() {
|
||||
return Err("missing mandatory /.well-known/security.txt in dist".to_string());
|
||||
}
|
||||
let sec_content = fs::read_to_string(&sec_txt)
|
||||
.map_err(|e| format!("failed to read security.txt: {}", e))?;
|
||||
for required_prefix in ["Contact: mailto:", "Encryption: https://", "Preferred-Languages:", "Canonical: https://", "Policy: https://", "Expires:"] {
|
||||
if !sec_content.contains(required_prefix) {
|
||||
return Err(format!(
|
||||
"RFC 9116 compliance failure: security.txt missing '{}'",
|
||||
required_prefix
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let mut files = Vec::new();
|
||||
collect_files(dist_dir, &mut files)?;
|
||||
files.sort();
|
||||
|
||||
let mut manifest_lines = Vec::new();
|
||||
for file_path in &files {
|
||||
let rel = file_path
|
||||
.strip_prefix(dist_dir)
|
||||
.map_err(|e| e.to_string())?
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
|
||||
let bytes = fs::read(file_path)
|
||||
.map_err(|e| format!("failed reading {}: {}", file_path.display(), e))?;
|
||||
|
||||
// Audit HTML files for zero 3rd-party scripts/fonts/forms/trackers
|
||||
if rel.ends_with(".html") {
|
||||
let html = String::from_utf8(bytes.clone())
|
||||
.map_err(|_| format!("non-UTF8 HTML file: {}", rel))?;
|
||||
audit_html(&rel, &html)?;
|
||||
}
|
||||
|
||||
let digest = sha256_hex(&bytes);
|
||||
manifest_lines.push(format!("{} /{}", digest, rel));
|
||||
}
|
||||
|
||||
if let Some(parent) = manifest_out.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let mut out = String::new();
|
||||
for line in &manifest_lines {
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
fs::write(manifest_out, out.as_bytes())
|
||||
.map_err(|e| format!("failed writing manifest: {}", e))?;
|
||||
|
||||
println!(
|
||||
"[htr-guard] Seal passed: {} output files audited (zero forms, zero 3rd-party assets) & SHA-256 hashed -> {}",
|
||||
manifest_lines.len(),
|
||||
manifest_out.display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn audit_html(rel_path: &str, html: &str) -> Result<(), String> {
|
||||
let lower = html.to_lowercase();
|
||||
|
||||
// No forms or interactive inputs allowed per security plan
|
||||
for forbidden_tag in ["<form", "<input", "<textarea", "<iframe", "<object", "<embed"] {
|
||||
if lower.contains(forbidden_tag) {
|
||||
return Err(format!(
|
||||
"{}: security audit failed — forbidden interactive/embed element '{}'",
|
||||
rel_path, forbidden_tag
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Strict CSP `style-src 'self'` cleanliness: forbid inline style="..." attributes
|
||||
if lower.contains(" style=\"") || lower.contains(" style='") {
|
||||
return Err(format!(
|
||||
"{}: security audit failed — inline style attribute detected (violates strict CSP style-src 'self')",
|
||||
rel_path
|
||||
));
|
||||
}
|
||||
|
||||
// No executable <script> tags allowed EXCEPT `<script type="application/ld+json">`
|
||||
let mut search_from = 0;
|
||||
while let Some(pos) = lower[search_from..].find("<script") {
|
||||
let abs = search_from + pos;
|
||||
let tail = &lower[abs..];
|
||||
let tag_end = tail
|
||||
.find('>')
|
||||
.ok_or_else(|| format!("{}: unclosed <script tag", rel_path))?;
|
||||
let open_tag = &tail[..=tag_end];
|
||||
if !open_tag.contains("type=\"application/ld+json\"") {
|
||||
return Err(format!(
|
||||
"{}: security audit failed — executable <script> tag detected ({})",
|
||||
rel_path, open_tag
|
||||
));
|
||||
}
|
||||
search_from = abs + tag_end + 1;
|
||||
}
|
||||
|
||||
// Ensure no external stylesheet or font links
|
||||
if lower.contains("fonts.googleapis.com")
|
||||
|| lower.contains("fonts.gstatic.com")
|
||||
|| lower.contains("googletagmanager")
|
||||
|| lower.contains("google-analytics")
|
||||
|| lower.contains("unpkg.com")
|
||||
|| lower.contains("jsdelivr.net")
|
||||
|| lower.contains("cdnjs.cloudflare.com")
|
||||
{
|
||||
return Err(format!(
|
||||
"{}: security audit failed — third-party tracker/CDN/font reference detected",
|
||||
rel_path
|
||||
));
|
||||
}
|
||||
|
||||
check_forbidden_claims(&lower).map_err(|e| format!("{}: {}", rel_path, e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
|
||||
for entry in fs::read_dir(dir).map_err(|e| e.to_string())? {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect_files(&path, out)?;
|
||||
} else {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Validation helpers ---
|
||||
|
||||
fn parse_kv_file(path: &Path) -> Result<BTreeMap<String, String>, String> {
|
||||
let raw = fs::read_to_string(path).map_err(|e| e.to_string())?;
|
||||
parse_kv_str(&raw.replace("\r\n", "\n"))
|
||||
}
|
||||
|
||||
fn parse_kv_str(input: &str) -> Result<BTreeMap<String, String>, String> {
|
||||
let mut map = BTreeMap::new();
|
||||
for (idx, line) in input.lines().enumerate() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let eq = trimmed
|
||||
.find(':')
|
||||
.ok_or_else(|| format!("line {}: expected 'key: value'", idx + 1))?;
|
||||
let key = trimmed[..eq].trim().to_string();
|
||||
let mut val = trimmed[eq + 1..].trim().to_string();
|
||||
if (val.starts_with('"') && val.ends_with('"') && val.len() >= 2)
|
||||
|| (val.starts_with('\'') && val.ends_with('\'') && val.len() >= 2)
|
||||
{
|
||||
val = val[1..val.len() - 1].to_string();
|
||||
}
|
||||
if map.contains_key(&key) {
|
||||
return Err(format!("line {}: duplicate key '{}'", idx + 1, key));
|
||||
}
|
||||
map.insert(key, val);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
fn require_field(map: &BTreeMap<String, String>, key: &str) -> Result<String, String> {
|
||||
map.get(key)
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| format!("missing required field '{}'", key))
|
||||
}
|
||||
|
||||
fn validate_htr_id(id: &str) -> Result<(), String> {
|
||||
// Format: HTR-YYYY-NNN (at least 3 digits at end)
|
||||
let parts: Vec<&str> = id.split('-').collect();
|
||||
if parts.len() != 3 || parts[0] != "HTR" {
|
||||
return Err(format!("invalid HTR ID '{}'; expected HTR-YYYY-NNN", id));
|
||||
}
|
||||
if parts[1].len() != 4 || !parts[1].chars().all(|c| c.is_ascii_digit()) {
|
||||
return Err(format!("invalid year in HTR ID '{}'", id));
|
||||
}
|
||||
if parts[2].len() < 3 || !parts[2].chars().all(|c| c.is_ascii_digit()) {
|
||||
return Err(format!("invalid sequence number in HTR ID '{}'", id));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_cve_id(cve: &str) -> Result<(), String> {
|
||||
let parts: Vec<&str> = cve.split('-').collect();
|
||||
if parts.len() != 3 || parts[0] != "CVE" {
|
||||
return Err(format!("invalid CVE ID '{}'; expected CVE-YYYY-NNNN+", cve));
|
||||
}
|
||||
if parts[1].len() != 4 || !parts[1].chars().all(|c| c.is_ascii_digit()) {
|
||||
return Err(format!("invalid year in CVE ID '{}'", cve));
|
||||
}
|
||||
if parts[2].len() < 4 || !parts[2].chars().all(|c| c.is_ascii_digit()) {
|
||||
return Err(format!("invalid sequence in CVE ID '{}'", cve));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_iso_date(d: &str) -> Result<(), String> {
|
||||
let b = d.as_bytes();
|
||||
if b.len() != 10
|
||||
|| b[4] != b'-'
|
||||
|| b[7] != b'-'
|
||||
|| !b[0..4].iter().all(|c| c.is_ascii_digit())
|
||||
|| !b[5..7].iter().all(|c| c.is_ascii_digit())
|
||||
|| !b[8..10].iter().all(|c| c.is_ascii_digit())
|
||||
{
|
||||
return Err(format!("invalid ISO date '{}'; expected YYYY-MM-DD", d));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_iso_datetime(dt: &str) -> Result<(), String> {
|
||||
if !dt.ends_with('Z') || !dt.contains('T') || dt.len() < 20 {
|
||||
return Err(format!(
|
||||
"invalid ISO-8601 UTC timestamp '{}'; expected YYYY-MM-DDTHH:MM:SSZ",
|
||||
dt
|
||||
));
|
||||
}
|
||||
validate_iso_date(&dt[..10])
|
||||
}
|
||||
|
||||
fn validate_email(email: &str) -> Result<(), String> {
|
||||
if email != "vahap@hacker.tr" {
|
||||
return Err(format!(
|
||||
"policy violation: single canonical email must be 'vahap@hacker.tr', got '{}'",
|
||||
email
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_fingerprint(fpr: &str) -> Result<(), String> {
|
||||
let clean: String = fpr.chars().filter(|c| !c.is_whitespace()).collect();
|
||||
if clean.len() != 40 || !clean.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(format!(
|
||||
"invalid 40-hex OpenPGP v4 fingerprint '{}'",
|
||||
fpr
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- JSON serializer (zero external crates, strict escaping) ---
|
||||
|
||||
fn json_escape(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() + 2);
|
||||
out.push('"');
|
||||
for ch in s.chars() {
|
||||
match ch {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
c if (c as u32) < 0x20 => {
|
||||
out.push_str(&format!("\\u{:04x}", c as u32));
|
||||
}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
out
|
||||
}
|
||||
|
||||
fn json_opt_str(opt: &Option<String>) -> String {
|
||||
match opt {
|
||||
Some(s) => json_escape(s),
|
||||
None => "null".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn json_map_str(map: &BTreeMap<String, String>) -> String {
|
||||
let mut parts = Vec::new();
|
||||
for (k, v) in map {
|
||||
parts.push(format!("{}: {}", json_escape(k), json_escape(v)));
|
||||
}
|
||||
format!("{{{}}}", parts.join(", "))
|
||||
}
|
||||
|
||||
fn json_map_vec(map: &BTreeMap<String, Vec<String>>) -> String {
|
||||
let mut parts = Vec::new();
|
||||
for (k, list) in map {
|
||||
let items: Vec<String> = list.iter().map(|s| json_escape(s)).collect();
|
||||
parts.push(format!("{}: [{}]", json_escape(k), items.join(", ")));
|
||||
}
|
||||
format!("{{{}}}", parts.join(", "))
|
||||
}
|
||||
|
||||
fn serialize_bundle(
|
||||
researcher_name: &str,
|
||||
email: &str,
|
||||
domain: &str,
|
||||
pgp_fpr: &str,
|
||||
pgp_asc: &str,
|
||||
security_expires: &str,
|
||||
advisories: &[SanitizedAdvisory],
|
||||
) -> String {
|
||||
let clean_fpr: String = pgp_fpr
|
||||
.chars()
|
||||
.filter(|c| !c.is_whitespace())
|
||||
.collect::<String>()
|
||||
.to_uppercase();
|
||||
|
||||
let grouped_fpr = clean_fpr
|
||||
.as_bytes()
|
||||
.chunks(4)
|
||||
.map(|c| std::str::from_utf8(c).unwrap_or(""))
|
||||
.collect::<Vec<&str>>()
|
||||
.join(" ");
|
||||
|
||||
let mut adv_json = Vec::new();
|
||||
for a in advisories {
|
||||
adv_json.push(format!(
|
||||
concat!(
|
||||
" {{\n",
|
||||
" \"htr_id\": {},\n",
|
||||
" \"status\": {},\n",
|
||||
" \"reported_date\": {},\n",
|
||||
" \"updated_date\": {},\n",
|
||||
" \"published_date\": {},\n",
|
||||
" \"cve_id\": {},\n",
|
||||
" \"vuln_class\": {},\n",
|
||||
" \"product\": {},\n",
|
||||
" \"affected_versions\": {},\n",
|
||||
" \"severity\": {},\n",
|
||||
" \"cvss\": {},\n",
|
||||
" \"summary_i18n\": {},\n",
|
||||
" \"timeline_i18n\": {}\n",
|
||||
" }}"
|
||||
),
|
||||
json_escape(&a.htr_id),
|
||||
json_escape(a.status.as_str()),
|
||||
json_escape(&a.reported_date),
|
||||
json_escape(&a.updated_date),
|
||||
json_opt_str(&a.published_date),
|
||||
json_opt_str(&a.cve_id),
|
||||
json_map_str(&a.vuln_class),
|
||||
json_opt_str(&a.product),
|
||||
json_opt_str(&a.affected_versions),
|
||||
json_opt_str(&a.severity),
|
||||
json_opt_str(&a.cvss),
|
||||
json_map_str(&a.summary_i18n),
|
||||
json_map_vec(&a.timeline_i18n),
|
||||
));
|
||||
}
|
||||
|
||||
format!(
|
||||
concat!(
|
||||
"{{\n",
|
||||
" \"researcher_name\": {},\n",
|
||||
" \"email\": {},\n",
|
||||
" \"domain\": {},\n",
|
||||
" \"pgp_fingerprint_raw\": {},\n",
|
||||
" \"pgp_fingerprint_grouped\": {},\n",
|
||||
" \"pgp_public_key\": {},\n",
|
||||
" \"security_txt_expires\": {},\n",
|
||||
" \"advisories\": [\n{}\n ]\n",
|
||||
"}}\n"
|
||||
),
|
||||
json_escape(researcher_name),
|
||||
json_escape(email),
|
||||
json_escape(domain),
|
||||
json_escape(&clean_fpr),
|
||||
json_escape(&grouped_fpr),
|
||||
json_escape(pgp_asc),
|
||||
json_escape(security_expires),
|
||||
adv_json.join(",\n")
|
||||
)
|
||||
}
|
||||
|
||||
// --- Pure-Rust FIPS 180-4 SHA-256 implementation ---
|
||||
|
||||
const K256: [u32; 64] = [
|
||||
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
||||
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
||||
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
||||
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
||||
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
||||
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
||||
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
||||
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
||||
];
|
||||
|
||||
fn sha256_hex(data: &[u8]) -> String {
|
||||
let mut h: [u32; 8] = [
|
||||
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
|
||||
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
|
||||
];
|
||||
|
||||
let bit_len = (data.len() as u64) * 8;
|
||||
let mut padded = Vec::with_capacity(((data.len() + 9 + 63) / 64) * 64);
|
||||
padded.extend_from_slice(data);
|
||||
padded.push(0x80);
|
||||
while (padded.len() % 64) != 56 {
|
||||
padded.push(0x00);
|
||||
}
|
||||
padded.extend_from_slice(&bit_len.to_be_bytes());
|
||||
|
||||
for chunk in padded.chunks_exact(64) {
|
||||
let mut w = [0u32; 64];
|
||||
for i in 0..16 {
|
||||
w[i] = u32::from_be_bytes([
|
||||
chunk[i * 4],
|
||||
chunk[i * 4 + 1],
|
||||
chunk[i * 4 + 2],
|
||||
chunk[i * 4 + 3],
|
||||
]);
|
||||
}
|
||||
for i in 16..64 {
|
||||
let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
|
||||
let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
|
||||
w[i] = w[i - 16]
|
||||
.wrapping_add(s0)
|
||||
.wrapping_add(w[i - 7])
|
||||
.wrapping_add(s1);
|
||||
}
|
||||
|
||||
let (mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut hh) =
|
||||
(h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7]);
|
||||
|
||||
for i in 0..64 {
|
||||
let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
|
||||
let ch = (e & f) ^ ((!e) & g);
|
||||
let temp1 = hh
|
||||
.wrapping_add(s1)
|
||||
.wrapping_add(ch)
|
||||
.wrapping_add(K256[i])
|
||||
.wrapping_add(w[i]);
|
||||
let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
|
||||
let maj = (a & b) ^ (a & c) ^ (b & c);
|
||||
let temp2 = s0.wrapping_add(maj);
|
||||
|
||||
hh = g;
|
||||
g = f;
|
||||
f = e;
|
||||
e = d.wrapping_add(temp1);
|
||||
d = c;
|
||||
c = b;
|
||||
b = a;
|
||||
a = temp1.wrapping_add(temp2);
|
||||
}
|
||||
|
||||
h[0] = h[0].wrapping_add(a);
|
||||
h[1] = h[1].wrapping_add(b);
|
||||
h[2] = h[2].wrapping_add(c);
|
||||
h[3] = h[3].wrapping_add(d);
|
||||
h[4] = h[4].wrapping_add(e);
|
||||
h[5] = h[5].wrapping_add(f);
|
||||
h[6] = h[6].wrapping_add(g);
|
||||
h[7] = h[7].wrapping_add(hh);
|
||||
}
|
||||
|
||||
let mut hex = String::with_capacity(64);
|
||||
for word in h {
|
||||
let _ = std::fmt::Write::write_fmt(&mut hex, format_args!("{:08x}", word));
|
||||
}
|
||||
let _ = Write::flush(&mut std::io::sink());
|
||||
hex
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_sha256_nist_vectors() {
|
||||
assert_eq!(
|
||||
sha256_hex(b""),
|
||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
);
|
||||
assert_eq!(
|
||||
sha256_hex(b"abc"),
|
||||
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unpublished_advisory_strips_secret_fields() {
|
||||
let tmp = std::env::temp_dir().join("htr_guard_test_redact.md");
|
||||
let sample = "\
|
||||
---
|
||||
id: HTR-2026-001
|
||||
status: reported
|
||||
reported_date: 2026-11-15
|
||||
internal_vendor: AcmeCorp
|
||||
internal_product: SuperGateway
|
||||
product: AcmeCorp SuperGateway
|
||||
affected_versions: <= 4.2.1
|
||||
severity: Critical
|
||||
cvss: 9.8
|
||||
class_en: Authentication Bypass in Enterprise Network Appliance
|
||||
---
|
||||
SECRET POC PAYLOAD THAT MUST NEVER LEAK BEFORE PATCH!
|
||||
";
|
||||
fs::write(&tmp, sample).unwrap();
|
||||
let sanitized = parse_and_redact_advisory(&tmp).unwrap();
|
||||
let _ = fs::remove_file(&tmp);
|
||||
|
||||
assert_eq!(sanitized.status, DisclosureStatus::Reported);
|
||||
assert!(sanitized.product.is_none());
|
||||
assert!(sanitized.affected_versions.is_none());
|
||||
assert!(sanitized.severity.is_none());
|
||||
assert!(sanitized.cvss.is_none());
|
||||
assert!(sanitized.cve_id.is_none());
|
||||
assert!(sanitized.summary_i18n.is_empty());
|
||||
assert!(sanitized.timeline_i18n.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vendor_leak_in_public_class_aborts_build() {
|
||||
let tmp = std::env::temp_dir().join("htr_guard_test_leak.md");
|
||||
let sample = "\
|
||||
---
|
||||
id: HTR-2026-002
|
||||
status: cve_requested
|
||||
reported_date: 2026-11-15
|
||||
internal_vendor: AcmeCorp
|
||||
class_en: Remote Code Execution in AcmeCorp Admin Service
|
||||
---
|
||||
";
|
||||
fs::write(&tmp, sample).unwrap();
|
||||
let res = parse_and_redact_advisory(&tmp);
|
||||
let _ = fs::remove_file(&tmp);
|
||||
assert!(res.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unassigned_cve_in_reported_stage_aborts_build() {
|
||||
let tmp = std::env::temp_dir().join("htr_guard_test_cve.md");
|
||||
let sample = "\
|
||||
---
|
||||
id: HTR-2026-003
|
||||
status: reported
|
||||
reported_date: 2026-11-15
|
||||
cve_id: CVE-2026-99999
|
||||
class_en: Memory Corruption
|
||||
---
|
||||
";
|
||||
fs::write(&tmp, sample).unwrap();
|
||||
let res = parse_and_redact_advisory(&tmp);
|
||||
let _ = fs::remove_file(&tmp);
|
||||
assert!(res.is_err());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue