Skip to content

Security hygiene scanning

Every response your API sends says something about itself: whether its cookies can be read by JavaScript, whether a shared proxy may cache it, whether a browser is allowed to frame it. Those answers are easy to assert one at a time — expect header "Strict-Transport-Security" contains "max-age" has always worked — and nobody writes them twice.

expect response has no security violations runs a built-in pack of ten hygiene rules over the response your last api step actually received.

api GET /orders
expect response has no security violations

That is the whole feature. The rest of this page is about what it checks, what it deliberately does not check, and the one line of config it requires first.

You have to say what you are allowed to scan

Section titled “You have to say what you are allowed to scan”

Before any of the above runs, tflw.config needs a declaration naming the target:

env secureLocal
api "https://localhost:8443/v1"
insecure true
allow hosts "localhost"
authorized target "https://localhost:8443" reason "self-hosted test fixture"

Without it, tflw check fails with TF060 and the run never starts.

This is a deliberate speed bump, not a formality. A test suite pointed at the wrong host is an inconvenience; a scanner pointed at the wrong host is somebody else's incident. The declaration is an affirmation, in writing, that you are permitted to do this — so it names one origin and takes no wildcards (https://*.example.com is TF061), and the reason is required because a declaration without one is a checkbox.

Your reason is printed in the run summary and embedded in report.html:

Terminal window
ℹ authorized target https://localhost:8443 — self-hosted test fixture

so the claim that permitted the scan travels in the same artifact as the scan's findings.

Loopback gets no exemption. http://localhost:4001 needs a declaration exactly like production does.

Ten rules, with fixed severities you filter rather than re-grade:

ruleseverityapplies when
sec/cookie-not-httponlycriticalthe response sets a cookie
sec/cookie-not-securecriticalthe scheme is https and the response sets a cookie
sec/cors-wildcard-with-credentialscriticalthe response carries Access-Control-Allow-Origin
sec/hsts-missingseriousthe scheme is https
sec/csp-missingseriousthe response is a document (text/html)
sec/tls-version-oldseriousthe scheme is https and the TLS probe succeeded
sec/tls-weak-cipherseriousthe scheme is https and the TLS probe succeeded
sec/x-frame-optionsmoderatethe response is a document (text/html)
sec/cookie-samesite-nonemoderatethe response sets a cookie
sec/nosniff-missingmoderatealways
sec/authenticated-response-cacheablemoderatethe request carried session or bearer credentials
sec/server-version-disclosureminoralways

A severity word is a floor, not an exact match, the same as the a11y scan in the a11y scan:

expect response has no serious security violations # serious and critical

Look at the third column of that table again. Most of those rules only mean something about certain responses.

Over plaintext, a Secure cookie is not merely unset — a browser would refuse to store it at all. Strict-Transport-Security over http:// is ignored. A Content-Security-Policy on a JSON body governs nothing. If the pack reported those as violations, a plaintext suite would light up on every single response, and every fix it suggested would break something.

So a rule whose precondition is unmet is not applicable — which is neither a violation nor a silent pass, and the result says so:

Terminal window
✓ expect response has no security violations — 12 rules — 2 applicable, 10 not applicable, 0 violations

Three numbers and their denominator, on pass and on failure alike. A green line that says 2 applicable is telling you something a bare ✓ cannot: this response was checked, and ten of the twelve questions did not apply to it.

An assertion that could not have failed, fails

Section titled “An assertion that could not have failed, fails”

Here is the case that catches people, and it is intentional:

api GET /orders # plain JSON, no cookies, no CORS
expect response has no critical security violations # ✗
Terminal window
✗ this assertion had no power to fail: no `critical`-or-worse security rule applied to this
response (3 rules — 0 applicable, 3 not applicable, 0 violations).
- sec/cookie-not-httponly applies when: the response sets a cookie
- sec/cookie-not-secure applies when: the scheme is https AND the response sets a cookie
- sec/cors-wildcard-with-credentials applies when: the response carries Access-Control-Allow-Origin
Point it at a response one of these rules can judge, or lower the severity floor.

All three critical rules are about cookies and CORS. This endpoint has neither, so nothing engaged. Passing would have written "checked and clean" into your report about a response that was never checked — and a green assertion nobody can fail is worse than no assertion, because it looks like coverage.

Note that the floor narrowed the pack to 3 rules, not 12. That is why the denominator is honest: it counts the work the assertion actually did.

The two TLS rules open a second connection

Section titled “The two TLS rules open a second connection”

sec/tls-version-old and sec/tls-weak-cipher are unlike the other ten: their facts are not in the response at all. tflw drives Node's built-in fetch, which will not tell you which protocol version or cipher suite a request negotiated, and the library that would is a runtime dependency tflw does not take.

So tflw opens its own connection to the same host and port, reads the protocol and cipher, and hangs up. Once per host per run — not once per response. It obeys allow hosts, and it needs an authorized target covering wherever the run actually ended up, which is checked again here because a redirect can land somewhere the config never named.

Three things follow, and they are worth knowing before you read a finding:

  • It is a second connection, not the one you asserted on. For a service with one TLS configuration these are the same thing. Behind a load balancer whose nodes are configured differently, they need not be — and the failure message says so rather than leaving you to discover it.

  • By default it reports what the host gives a current client, not everything the host would accept. A server that still supports RC4 but prefers AES-GCM will negotiate AES-GCM, and tls-weak-cipher stays correctly silent — because that is what your callers get. Asking the wider question costs one handshake per candidate suite, so it is opt-in: probe ciphers below.

  • If the handshake cannot be made, both rules are "not applicable" — never a failure. A refused connection, a timeout, or a self-signed certificate on a run without insecure true gets you:

    Terminal window
    - sec/tls-version-old applies when: the scheme is https and the TLS probe succeeded — it did
    not: the TLS handshake to localhost:8443 failed: DEPTH_ZERO_SELF_SIGNED_CERT

    A network problem is not a security verdict, and the listing tells you which half went wrong.

One deliberate asymmetry: the probe offers to speak TLS 1.0, which is older than Node's own client will normally go. It has to. A server that speaks nothing but TLS 1.0 would otherwise refuse the handshake, and the rule whose entire job is to catch that server would report "could not tell". Offering an old floor never drags a healthy server down — the server still picks the newest version you both speak, so a TLS 1.3 host is still reported as TLS 1.3.

probe ciphers — asking what the host offers

Section titled “probe ciphers — asking what the host offers”

The default reading has a false negative it cannot see. A server that still offers RC4 alongside AES-GCM negotiates AES-GCM with tflw's own client, reads clean, and stays clean — while an older client on the same host gets RC4. Answering what does this host offer? rather than what did it give me? takes one handshake per candidate suite, and that is a fourth sub-clause on the authorized target:

defaults
authorized target "https://localhost:8443" reason "self-hosted test fixture"
probe ciphers

With it on, sec/tls-weak-cipher reports every suite the host accepted, not just the one it negotiated. It is opt-in per host for the reason the other three sub-clauses are: this is the one construct in the language whose purpose is many connections to one host, and a scan that opens many connections should be something you asked for on a host you named.

Four things the widened rule is careful to say out loud, because a cipher enumerator that is quiet about its own limits is worse than one that never ran:

  • Withheld is announced, not passed over. Without the opt-in, the passing line carries note: sec/tls-weak-cipher judged only the suite this host gave tflw's own client …. Silence would recreate exactly the false negative the clause exists to remove — so the note prints on green lines too.
  • Unprobed is never rendered as clean. A host with no probe ciphers is absent from the offer report rather than present with an empty one.
  • The ceiling is printed. Enumeration can only offer what tflw's own OpenSSL will put in a ClientHello, and on OpenSSL 3.x that is a smaller set than you would guess: 10 of the 18 candidates — RC4, 3DES, single DES and the EXPORT families among them — cannot be offered at all. Those come back as unaskable, reported separately from the ones the server actually declined, because a suite nobody offered says nothing about the server.
  • The enumerating connection does not verify the certificate, and that is a decision rather than an oversight. Reaching a legacy suite needs @SECLEVEL=0, which also lowers what counts as an acceptable certificate. It is admissible only because that connection reads exactly one bit — did this peer accept this suite — sends no credential, transfers no application data and reads no body. sec/tls-version-old keeps reading the single verifying probe.

Your session's login response is scanned too

Section titled “Your session's login response is scanned too”

The single most valuable thing this pack can find is a session cookie without HttpOnly — and that cookie is set by your session block, not by the endpoint you are asserting about.

So each session's own login response is scanned once, when the session is established, and its findings are folded into every security assertion in a test that uses it:

Terminal window
✗ expected response to have no security violations, but found 1 — 12 rules — 2 applicable, …:
- [critical] sec/cookie-not-httponly: cookie is readable by JavaScript (no HttpOnly)
(session "admin" login — cookie `sid` — any XSS on this origin can read it)

Nothing inspects a cookie jar or reaches across requests: that login response is a response your run genuinely made.

Assertions are explicit, one per step. There is no config key that scans every response automatically — a hidden assertion that no line of your test file shows would mean a passing suite's guarantees are no longer readable from the suite.

There is also no hook shortcut. This language has no before each/after each, and a bare after hook runs in its own scope where it never inherits the test's last response (TF039). So the scan sits in the test body, next to the request it judges:

test "order endpoints answer safely"
api GET /orders
expect response has no serious security violations
api GET /orders/1
expect response has no serious security violations

That is more typing than a hook would be, and it is a deliberate trade: every guarantee this suite claims is a line somebody can read in the file that claims it.

  • No authorization testing — in this matcher. The pack reads what a response says about itself. Whether user A can fetch user B's order is a different question, and a much more valuable one: it is authorization testing's has no authorization violations, a separate matcher on the same response subject. Deliberately separate — folding it in here would have made every assertion on this page start sending cross-identity traffic the moment its author upgraded.
  • No per-rule suppression. A severity floor is the only filter. Allow-listing individual rule ids is deliberately unsupported, the same as for the a11y scan.
  • No SARIF or standalone scan report. A finding here is an assertion failure: it surfaces in the console and in report.html like any other.