Skip to content

Measure the security posture from the outside, daily - #371

Merged
Apolloccrypt merged 5 commits into
mainfrom
feat/security-posture
Sep 3, 2026
Merged

Measure the security posture from the outside, daily#371
Apolloccrypt merged 5 commits into
mainfrom
feat/security-posture

Conversation

@Apolloccrypt

@Apolloccrypt Apolloccrypt commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Wat dit toevoegt

Een dagelijkse meting van buitenaf, scripts/security/posture.sh, plus de workflow die hem draait. Nergens ingelogd, niets op productie gewijzigd. 108 metingen:

groep wat
tls certificaat, verlooptermijn en SAN op zes hostnamen; TLS 1.0/1.1 moet weigeren, 1.2/1.3 moet aangeboden worden
headers zes securityheaders op /, /sign, /pricing, /parasign en op de vijf sector-origins apart
dns SPF, DMARC, CAA, DNSSEC, DKIM-selector resend
paraid POST /v1/paraid/issue-document moet 404 geven op alle zes ingangen
audit npm audit in root, relay en admin; de Rust-lockfile tegen de advisorydatabase; alleen high en critical laten falen
seo elke sitemap-url 200, geen disallow op een sitemap-url, robots noemt de sitemap

De uitslag van de eerste scan

Vanaf de NUC tegen de live site, stempel 2026-09-03 00:14 UTC. 90 groen, 19 rood. Het volledige rapport zit als artifact aan elke run.

De negentien rode metingen vallen in twee helften. Tien zijn de ontbrekende X-Frame-Options en de lege Permissions-Policy op de vijf sector-relays; die zijn in deze PR gefixt, maar pas waar op productie zodra het relay-image is uitgerold. De negen andere kan geen merge veranderen: vijf keer een dubbele HSTS, geen CAA, een niet-ondertekende zone, een live sitemap met twee urls die 302 geven, en een ongeclassificeerd Rust-advies.

Rood, en gerepareerd in deze PR

X-Frame-Options ontbrak op alle vijf sector-relays. paramant.app stuurde DENY, relay/health/legal/finance/iot stuurden niets. nginx-paramant-public.conf zet op die vijf vhosts alleen HSTS, dus wat setHeaders in relay.js weglaat is simpelweg afwezig. Toegevoegd.

Permissions-Policy: interest-cohort=() op diezelfde vijf. FLoC is in 2022 ingetrokken en interest-cohort is nooit in het feature-register opgenomen. Een policy die alleen dat noemt parseert tot een lege policy: de header is aanwezig, komt door elke scanner die enkel op aanwezigheid toetst, en beperkt niets. Vervangen door geolocation=(), microphone=(), camera=(), payment=(), usb=().

Dit is dezelfde vorm als de bevinding van 02-09 over de heartbeat: afwezigheid die zich voordoet als aanwezigheid. Daarom toetst de scanner op de inhoud van elke header, niet op de naam.

Twee high-advisories in de root-lockfile (brace-expansion, undici, beide dev-only en transitief onder javascript-obfuscator). Opgelost met npm audit fix --package-lock-only, zes regels lockfile, geen wijziging aan package.json. npm audit --omit=dev was al schoon; de poort faalt op high en critical ongeacht dev, zodat er geen ontsnapping in zit.

Rood, en een besluit voor jou (DNS)

Geen CAA-record op paramant.app. Elke CA in elke trust store mag vandaag een certificaat voor het domein uitgeven. De zone staat bij Bunny (kiki.bunny.net, coco.bunny.net). Voorstel:

paramant.app.  CAA  0 issue "letsencrypt.org"
paramant.app.  CAA  0 issuewild ";"
paramant.app.  CAA  0 iodef "mailto:privacy@paramant.app"

De zone is niet ondertekend. Geen DS-record bij de ouder, geen ad-vlag in het antwoord. Met DNSSEC uit is elk record hierboven, CAA inbegrepen, te vervalsen door wie het pad naar de resolver beheerst. DNSSEC eerst, dan pas is de CAA-regel hard.

Wat al goed staat: SPF (~all), DMARC (p=quarantine), en de DKIM-sleutel op resend._domainkey.paramant.app. Die selector is niet uit de repo af te leiden en is via DNS vastgesteld.

Rood, en een besluit voor jou (server)

HSTS wordt twee keer gestuurd door de vijf sector-relays. relay.js zet hem, nginx-paramant-public.conf zet hem er nog eens overheen, en daarboven staat Caddy. Een dubbele header is dubbelzinnig voor clients en verbergt welke laag hem zette. Ik heb hem bewust niet uit relay.js gehaald: de juiste eigenaar is de laag die TLS termineert, en de Caddy-configuratie staat nergens in deze repo, dus van hieruit is niet te zien welke van de drie moet wijken. Jouw keuze.

deploy/nginx-*.conf komt niet op de server terecht. Nagetrokken: geen enkel script kopieert die bestanden. deploy.sh raakt nginx niet aan, install.sh bedoelt de gecontaineriseerde nginx van de self-host-stack, docker-compose.server.yml verwijst naar een deploy/nginx-paramant.conf die niet bestaat, en scripts/check-prod-drift.sh bewaakt alleen frontend/. deploy/fix-nginx-ports.py en deploy/signup-fix-deploy.sh bewerken /etc/nginx/sites-enabled/* rechtstreeks en zijn het onderling oneens over de bestandsnaam. De confs in de repo zijn dus documentatie die vrij mag afdrijven. Daarom staat de header-fix in relay.js en niet in de conf: dat is de laag die via het image wél op productie komt.

Productie loopt achter op main. De live sitemap is die van 21-07 (59 urls, kop Generated by bron-seo/build_sitemap.py), main heeft die van 02-09 (40 urls). Het verschil is precies het werk van 02-09: PR #323 snoeide 17 normen- en sectorpagina's, #325 en #339 gaven ParaSign en ParaSend een pagina. Live serveert die 17 nog en adverteert ze; /parasend en /parasign geven live 404. De twee rode sitemap-urls (/sign en /parashare, beide 302 naar de login) staan in de live sitemap, niet in die van main, waar /parashare al in de PRIVATE-lijst van bron-seo/build_sitemap.py zit. Er valt hier niets in de repo te repareren; het wacht op een deploy.

Wat groen was en het vermelden waard is

De ParaID-deny houdt. Alle zes de ingangen geven 404 op POST /v1/paraid/issue-document. Let wel: die deny staat nergens in deze repo. relay.js:2129 kan 200, 400, 429 en 503 geven en nooit 404. Op de apex valt het verzoek door naar de statische tier en 404t bij toeval; op de vijf subdomeinen moet er een serverregel staan die alleen op de server bestaat. De meting toetst de eigenschap die telt en is een goede regressiemelder, maar het mechanisme is niet in git te lezen.

Verder: certificaat van Let's Encrypt geldig tot 13-10 met alle zes hostnamen in de SAN, TLS 1.0 en 1.1 geweigerd op alle zes, de CSP op alle zes, X-Content-Type-Options en Referrer-Policy overal, en de npm-audits schoon in alle drie de trees.

Waarom de geplande run achter een schakelaar zit

Negen rode metingen kan geen merge veranderen en tien wachten op een deploy. Zonder rem zou dit elke ochtend rood gaan om een lijst die iedereen al kent, en een alarm dat dagelijks huilt om een bekende reden is er een die mensen leren negeren. Dat is precies hoe de vorige monitor doodging. Dus dezelfde constructie als heartbeat.yml:

if: >-
  github.event_name == 'workflow_dispatch' ||
  (github.event_name == 'schedule' && vars.SECURITY_POSTURE_ENABLED == 'true')

workflow_dispatch negeert de rem, dus de scan is nu al met de hand te starten. Zet SECURITY_POSTURE_ENABLED op true zodra de deploy is gedaan en de besluiten hierboven zijn uitgevoerd. De rem zit op het schema, niet op een meting: elke afzonderlijke meting faalt hard, ook nu.

Wat er naar de bewaker zelf kijkt

tests/security-posture-dryrun.sh vervangt curl, openssl, dig en npm door stubs en draait de scanner drie keer:

== green: every answer correct ==
ok   exit 0
ok   no red rows
ok   108 measurements ran

== red: every answer wrong ==
ok   exit 1
ok   no green rows
ok   every one of the 108 measurements went green once and red once
ok   108 ::error lines, one per red measurement

== missing: the services say nothing ==
ok   exit 1
ok   nothing green when nothing answered
ok   silence reported as: no certificate returned
   ...
PASS: the scanner reports green when things are right and red when they are not.

De gelijkheidstest tussen de groene en de rode ronde is het hart ervan: een meting die maar in één van de twee voorkomt is een meting die niet van gedachten kan veranderen. Die test vond er tijdens het bouwen meteen één, sitemap.xml is served, die groen werd geboekt onder een andere naam dan waaronder hij rood werd. Rechtgezet.

Deze job draait op elke pull request. De live job niet.

Waarom OSV en niet cargo audit --json

Het RustSec-advisoryrecord draagt een CVSS-vector en geen severity-woord. Een poort geschreven tegen een severity-veld dat er niet is leest undefined voor elk advies, zet ze allemaal weg als ongeclassificeerd, en slaagt voor altijd. Dezelfde vorm als een kanarie die zijn eigen skip als pass rapporteert, dus de severity komt van een bron die er wél een noemt. cargo audit blijft met de hand bruikbaar; het is niet wat deze poort leest. De lokale cargo-audit 0.21.1 kan de database van vandaag trouwens niet meer parsen (unsupported CVSS version: 4.0) en 0.22 vraagt rustc 1.88, wat dit nog eens onderstreept.

Tests gedraaid

  • bash -n over beide nieuwe scripts en alle stubs
  • tests/security-posture-dryrun.sh: PASS, 108 metingen beide kanten op
  • bash tests/static-sanity.sh: PASS, alle elf checks
  • scripts/check-commit-style.sh: OK
  • node --check relay/relay.js
  • de live scan zelf, tweemaal, exitcode 1 met 18 rood
  • CI op deze PR: alles groen, inclusief relay - crypto suite waar de nieuwe headertest draait

relay/test/route-security-headers.test.js heet route-* zodat hij automatisch in de relay-crypto-tests-job landt (die glob boot een echte relay.js met deps en redis) en uit de unit-job blijft, precies zoals de andere suites die relay.js booten. Lokaal niet te draaien: de native engine is hier niet gebouwd. De zes assertions zijn wel apart getoetst tegen de echte headers van vóór en na de fix; de twee die de defecten raken falen op de oude waarden en slagen op de nieuwe, de andere vier zijn regressiewachters op wat al goed stond.

Shellcheck stond niet op mijn machine, maar de runner heeft hem wel, en de eerste run van de nieuwe job liet dat merken door te falen op een variabele die ik declareerde en nooit gebruikte. Daarna heb ik de binary lokaal gehaald en er alles doorheen gehaald: nog drie vondsten, waaronder een letterlijke accolade in een case-patroon in de curl-stub. Alle vijf de bestanden zijn nu schoon op style-niveau, en de job draait shellcheck ook over de stubs. Een poort die juist de slordigste bestanden overslaat is geen poort.

Herstelronde na de review

Must-fix, en de reviewer had gelijk: de Rust-poort was blind. Live nagemeten tegen api.osv.dev. Een RustSec-advies heeft geen database_specific.severity; RUSTSEC-2020-0071 en RUSTSEC-2021-0079 dragen daar alleen {"license": "CC0-1.0"} en zetten hun ernst in severity[] als CVSS_V3-vector. RUSTSEC-2026-0190 heeft geen van beide. Mijn poort las dat ene veld, kreeg voor elk RustSec-advies een lege string, viel in de *)-tak en faalde nooit. RUSTSEC-2021-0079 scoort 9,1 en was er zo doorheen gelopen.

Het sabotagebewijs, hetzelfde advies door de oude en de nieuwe classifier:

Advisory: RUSTSEC-vorm, CVSS 9.1, geen database_specific.severity

OUDE code    band=unrated   rood=NEE  (database_specific.severity)
NIEUWE code  band=critical  rood=JA   (cvss3 9.1)

scripts/security/osv-severity.mjs rekent nu de CVSS v3.1-basisscore uit volgens de specificatie, inclusief de Roundup-functie (gewoon afronden geeft 6,1 waar de spec 6,2 zegt). Het neemt de zwaarste van de vector en het gepubliceerde woord, want die twee verschillen met opzet: over zeven GHSA-adviezen die beide dragen kwamen er vijf overeen, één was door GitHub opgewaardeerd en één afgewaardeerd. Een poort die één van de twee alleen gelooft, is uit een bevinding te praten.

CVSS_V4 reken ik bewust niet uit: dat is een opzoektabel, geen formule, en een verkeerde implementatie is erger dan geen. Een v4-advies gaat via het gepubliceerde woord, en zonder woord is het unknown. Unknown is rood. Niet weten hoe erg iets is, is een besluit voor een mens.

Ook toegevoegd: results moet één entry per bevraagde crate bevatten. Een lege of korte array las eerst als "geen kwetsbaarheden" en gaf 114 crates een schone verklaring.

tests/osv-severity.test.mjs pint dit op de echte responsvormen vast, zeven tests, inclusief dat een 9,1 RustSec-advies de poort laat vallen. De dry run kreeg een RustSec-vormige stub en telt nu 109 metingen, elk één keer groen en één keer rood.

Gevolg voor de uitslag: de scan gaat van 18 naar 19 rood. De negentiende is RUSTSEC-2026-0190, een anyhow-unsoundness zonder enige ernstclassificatie in welke database dan ook. Die liet de blinde poort door. Dit is een besluit voor jou: accepteren en pinnen, of anyhow bumpen.

Aanrader 2: de issue lekte details op een publieke repo. De workflow schreef bij rood de volledige lijst rode metingen in een publiek issue, inclusief regels als "the unauthenticated issuing route is reachable again". Dat is een openstaande deur voorlezen op straat. Het issue bevat nu alleen de titel, het aantal rood en de run-link; de details blijven in het artifact, dat alleen bereikbaar is voor wie de Actions-tab al mag zien.

Aanrader 3: de sitemap-lus curlde elke <loc> ongevalideerd. Wie de sitemap op de webserver kan bewerken, kon de scanner op een derde partij richten. De lus accepteert nu alleen paramant.app en subdomeinen; een off-origin url wordt niet opgehaald maar als aparte rode meting gerapporteerd.

Aanrader 4: alle zeven actions op commit-sha, zoals test.yml het doet. De github-script-sha heb ik via de API opgezocht en geverifieerd, nadat ik er eerst een had ingetypt die niet bestond.

Kleine bijvangst: npm audit is een netwerkaanroep en gaf tijdens een scan eenmalig onparseerbare uitvoer, wat terecht rood werd. Er zit nu één herkansing voor, die nog steeds rood eindigt als ook de tweede faalt.

Alle poorten opnieuw gedraaid: dry-run 109 metingen PASS, static-sanity alle elf, check-test-declarations 117 suites, eslint schoon, bash -n over alles, shellcheck schoon op style-niveau. CI op deze PR: twaalf groen, één overgeslagen (de gated productie-job). Shellcheck staat niet op deze machine; de selftest-job vraagt erom en waarschuwt zichtbaar als de runner hem mist.

@Apolloccrypt
Apolloccrypt force-pushed the feat/security-posture branch 3 times, most recently from 6cec439 to 0bc25c7 Compare September 3, 2026 00:15
Every gate in this repo reads the checkout. None reads the certificate,
the DNS zone, the response headers of the five sector relays, or whether
the ParaID issuing route is still shut. Those live on the server and in
the zone, and there is no drift guard for either.

scripts/security/posture.sh takes 108 measurements over TLS and cert
expiry on six hosts, the six security headers on four apex paths and the
five relay origins, SPF, DMARC, CAA, DNSSEC and the Resend DKIM
selector, the ParaID deny on six ingresses, npm audit in three trees,
the Rust lockfile against the advisory database, and robots.txt against
sitemap.xml. It logs in nowhere and changes nothing.

No measurement has an escape. A missing tool, a silent host or an
unparseable answer is red with the name of what is missing, never green
with a reason. tests/security-posture-dryrun.sh drives all 108 both ways
against stubbed curl, openssl, dig and npm and fails if any of them can
only report one direction.

The first live scan came back 90 green, 18 red. Ten of the red are two
faults in setHeaders in relay.js, both true for as long as that function
existed: the five sector relays sent no X-Frame-Options at all, and
their Permissions-Policy named interest-cohort, which was withdrawn in
2022 and never entered the feature registry, so the policy parsed to an
empty policy. Present, scanner-clean, restricting nothing. Both fixed
here, and true on production once the relay image is deployed.

Also two high advisories in the root lockfile, dev-only and transitive,
resolved by lockfile bump alone.

The remaining eight no merge can touch: a duplicated HSTS on those same
five relays, where the owning layer is a Caddy config this repo cannot
see, no CAA record, an unsigned zone, and a live sitemap still
advertising two pages that answer 302. So the scheduled job is held
behind SECURITY_POSTURE_ENABLED, as heartbeat is. workflow_dispatch
ignores the gate.
The usage block still promised "two repo-local audits (npm, cargo)"
after the Rust half moved to the advisory database. A comment describing
code that is no longer there is the failure this scanner was written to
catch, so it does not get to sit in the scanner's own header.
Verified live against api.osv.dev on 2026-09-03. A RustSec advisory has
no database_specific.severity. RUSTSEC-2020-0071 and RUSTSEC-2021-0079
both carry database_specific {"license": "CC0-1.0"} and put the severity
in severity[] as a CVSS_V3 vector; RUSTSEC-2026-0190 has neither. The
gate read database_specific.severity, so every RustSec advisory resolved
to the empty string, fell to the branch that does not fail, and passed.
RUSTSEC-2021-0079 scores 9.1 and would have gone straight through.

scripts/security/osv-severity.mjs now scores the CVSS v3.1 vector by the
specification formula and takes the WORSE of that and the published
word, because the two disagree by design: over seven GHSA advisories
carrying both, five agreed, one was rated up and one rated down. CVSS_V4
is a lookup table rather than a formula and is deliberately not scored;
a v4 advisory resolves through the published word, and with no word it
is unknown. Unknown is red. Not knowing how bad something is has to be a
human decision, never one the gate makes for itself.

The batch reply is now required to hold one result per crate asked
about. An empty or short results array used to read as "no
vulnerabilities" and hand 114 crates a clean bill of health.

tests/osv-severity.test.mjs pins all of it against the real response
shapes, including that a 9.1 RustSec advisory fails. The dry run gained
a RustSec-shaped stub: the old classifier calls it unrated and stays
green, the new one calls it critical and goes red.

Three more from the same review. The issue this workflow opens on a
public repository now carries a count and a run link only; the list of
red measurements, which included lines naming a reachable route, stays
in the artifact. The sitemap loop refuses any loc that is not on
paramant.app instead of curling wherever the file points. All seven
actions are pinned to a commit sha, as the other workflows are.

The live scan is now 90 green and 19 red. The nineteenth is
RUSTSEC-2026-0190, an unrated advisory the blind gate had been passing.
The runner does have shellcheck, which the first run of the new job
proved by failing on a variable declared and never used. Three more came
out once it was pointed at everything: a literal brace in a case pattern
in the curl stub, an unquoted expansion inside a parameter substitution,
and a tr range that only covers ASCII.

The dry run no longer asserts with cond && ok ... || bad ...  That chain
looks like if-then-else and is not: if ok ever failed, bad would run too
and the suite would report a failure that had not happened. In the file
whose entire job is to be believed, that is not a shortcut worth the
line it saves.

The step now runs at style level over the stubs as well. A guard that
skips the files most likely to be sloppy is not much of a guard, and it
was the stubs that carried the brace bug.
Since #378 the dry-run suite derives phase 0a's gated list from the
workflow files, and security-posture.yml had a push-to-main trigger, so
it belonged in that list and was not in it.

Adding it would have been the wrong repair. The gate reads the last
completed run of each listed workflow on main, and for this one that is
the nightly external scan. That scan is red on purpose: a missing CAA
record, an unsigned zone, an HSTS header duplicated by a layer above
this repository, and one Rust advisory that carries no severity in any
database and needs a human ruling. Every one of those describes the DNS
zone or the server. None of them says anything about whether main is
deployable, and gating on them is the mistake the runbook already
records about heartbeat.yml.

So the push trigger is gone and the exclusion is written down in all
three places that state it: the comment above REQUIRED_WORKFLOWS, the
--dry-run printout, and DEPLOY-3.1.md.

The selftest loses nothing. It still runs on every pull request, which
is how every change reaches main, and it is the only thing standing
between a green tick and a scanner that cannot report red.

Dry-run suite: 259 passed, 0 failed.
@Apolloccrypt
Apolloccrypt merged commit 789b0e9 into main Sep 3, 2026
14 checks passed
@Apolloccrypt
Apolloccrypt deleted the feat/security-posture branch September 5, 2026 18:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant