feat: initial open-source release of htr-guard (Rust) & htr-site (Go) under MIT License

This commit is contained in:
Vahap Eren 2026-11-15 18:19:43 +00:00
commit 51d1b93d06
15 changed files with 5636 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
/rust-guard/target/
/htr-site
/dist/
*.tmp

21
LICENSE Normal file
View 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
View 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).

View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,3 @@
module hacker.tr/htr-site
go 1.22

1642
go-site/i18n.go Normal file

File diff suppressed because it is too large Load diff

680
go-site/main.go Normal file
View 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

File diff suppressed because it is too large Load diff

7
rust-guard/Cargo.lock generated Normal file
View 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
View 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
View 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());
}
}