Security Response Header Regression Gate avatar

Security Response Header Regression Gate

Pricing

from $10.00 / 1,000 run starteds

Go to Apify Store
Security Response Header Regression Gate

Security Response Header Regression Gate

Learn when a deploy, a CDN rule or a proxy change removes or weakens a security header on your production URLs. The Actor compares HSTS, CSP (each directive), X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy and the cross-origi

Pricing

from $10.00 / 1,000 run starteds

Rating

0.0

(0)

Developer

kingii98

kingii98

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

4 days ago

Last modified

Categories

Share

Your security headers are correct today. This Actor tells you when a deploy, a CDN rule, a WAF rule or a proxy change removes or weakens one of them.

The Actor does not give a grade from 0 to 100. It compares the headers of each URL with the headers of a stored baseline. It reports each header that changed, with a change class and a severity. It sets gate_pass to false when a regression is at or above the severity that you select. Call it from your CI job after each production deploy, and put it on a daily schedule to catch changes that do not come from a deploy.

What the Actor reads

The Actor sends one GET request to each URL. It follows up to 5 redirects and reads the headers of the final response. It never reads the response body. It uses HTTP only: no browser and no proxy.

It reads these ten headers:

  • Strict-Transport-Security (HSTS)
  • Content-Security-Policy (CSP) and Content-Security-Policy-Report-Only
  • X-Frame-Options
  • X-Content-Type-Options
  • Referrer-Policy
  • Permissions-Policy
  • Cross-Origin-Opener-Policy, Cross-Origin-Embedder-Policy and Cross-Origin-Resource-Policy

It does not read CORS headers or cache and compression headers.

What you get

  • One summary record for each run: the URL count, the changed URL count, the regression count for each severity, and the boolean gate_pass.
  • One change record for each header that changed on one URL: the URL, the final status, the header name, the baseline value, the current value, the change class, the severity, and a list of details.
  • For CSP, the details give the change for each directive, for example 'unsafe-inline' added to script-src, wildcard source * added to script-src or frame-ancestors removed.
  • For HSTS, the details give max-age lowered from 31536000 to 86400, includeSubDomains removed or preload removed.
  • One policy violation record for each rule of your optional policy that a URL breaks.
  • One URL error record for each URL that the Actor could not check.

A URL with no change gives no record. The summary counts it.

Change classes

Change classMeaningRegression
removedThe baseline had the header, and the response does not.yes
weakenedThe header gives less protection than the baseline.yes
strengthenedThe header gives more protection than the baseline.no
addedThe response has a header that the baseline did not have.no
value-changedThe value changed, but the protection did not. For example a new CSP hash or a new report target.no

A reordered directive list, a new CSP nonce, other case or other white space is no change.

Severities

SeverityExamples
criticalHSTS max-age=0 (browsers delete their HSTS entry).
highHSTS or CSP removed. HSTS max-age lowered, or includeSubDomains removed. A CSP directive removed from default-src, script-src, object-src, base-uri or frame-ancestors. 'unsafe-inline', 'unsafe-eval', * or a scheme source (https:, data:) added to one of these directives. 'unsafe-inline' in effect again because the nonce or hash is gone.
mediumX-Frame-Options, X-Content-Type-Options or COOP removed or weakened. HSTS preload removed. Another CSP directive removed. An unsafe keyword or a wildcard added to another CSP directive. 'strict-dynamic' removed. A Permissions-Policy feature opened to every origin.
lowReferrer-Policy, Permissions-Policy, COEP, CORP or CSP-Report-Only removed or weakened. A new host source in CSP. X-Frame-Options removed while CSP frame-ancestors is present.
infoEvery change that is not a regression.

A change in Content-Security-Policy-Report-Only is value-changed with the severity info, because a report-only policy blocks nothing.

The baseline

The Actor keeps the last normalized header set of each URL in the named key-value store security-header-baseline. The record key is baseline_name. If you leave baseline_name empty, the Actor uses one record for each URL set.

  • First run for a URL: the Actor writes the baseline and reports no regressions.
  • Later run: the Actor compares the headers with the baseline.
  • A regression at or above fail_on keeps its baseline value. The gate stays failed on each run until the header comes back, or until you reset the baseline.
  • Every other change becomes the new baseline value, so the Actor reports it one time only.
  • Reset: set reset_baseline to true after you removed or changed a header on purpose. The Actor writes the current headers as the baseline and reports no changes.
  • A URL that the Actor could not check keeps its baseline.

The Actor writes the record one time, at the end of the run. An aborted run does not change the baseline. The record holds at most 1,000 URLs.

Protection against false regressions

A stability probe of 25 public sites on 2026-09-11 found stable headers on all 25 after normalization (docs/stability-probe.md). The Actor also does these things:

  1. It masks CSP nonces, and it ignores the query of a report-uri and the parameters of COOP and COEP.
  2. When a URL shows a regression, the Actor requests the URL one more time. It reports only the regressions that the second response repeats. The summary counts the others in unconfirmed_regressions. Sometimes the second request gets no usable answer: it times out, it cannot connect, it reaches the run deadline, or its status class is different. Then the Actor cannot confirm or reject the regressions. It reports each of them with confirmed: false, and it charges each of them as regression-flagged.
  3. When a URL with a 2xx baseline answers 4xx (for example a bot check), the Actor does not compare the headers. The URL error record has the reason code STATUS_CHANGED, and the baseline stays. When the URL answers 2xx after a baseline with another status, the Actor writes a new baseline.
  4. A 5xx or 429 answer is retried one time. After the retry it is the reason code SERVER_ERROR, and the Actor does not compare it.

The gate

gate_pass is false when a regression, or a policy violation, has a severity at or above fail_on. The default is high. A policy violation has the severity high.

A failed gate is a result, not a fault. The Actor writes it to the dataset and to the status message, and the run succeeds. The same applies to an unreachable URL and to an unusable input entry. Your CI job reads gate_pass from the summary record.

Input

FieldTypeDefaultMeaning
urlsarray of 1 to 200 URLsthree public demo URLsThe URLs to check. The Actor does not crawl.
required_headersobject{}Optional policy. See below.
fail_oncritical, high, medium or lowhighThe lowest severity that sets gate_pass to false.
baseline_namestringemptyThe record key of the baseline. Empty: one record for each URL set.
reset_baselinebooleanfalseWrite the current headers as the new baseline.
alert_webhook_urlHTTPS URLemptyOne JSON POST when the run finds a regression.
request_timeout_secondsinteger 2 to 6015Timeout for each request and the webhook POST.
concurrencyinteger 1 to 2010URLs requested at the same time.
max_redirectsinteger 0 to 105Redirect hops for each request.
max_run_secondsinteger 30 to 3600240Deadline for the URL list and the confirmation requests.

Every field has a schema default. A run with the empty input {} checks the three demo URLs and succeeds.

The required header policy

Each key is one of the ten headers. Each value is true (the header must be present) or an object of rules:

{
"strict-transport-security": {"min_max_age": 31536000, "include_subdomains": true},
"content-security-policy": {
"required_directives": ["frame-ancestors", "object-src"],
"forbidden_sources": {"script-src": ["'unsafe-inline'", "'unsafe-eval'"]}
},
"x-frame-options": {"allowed_values": ["DENY", "SAMEORIGIN"]},
"x-content-type-options": true
}
  • allowed_values (all headers): the normalized value must be one of these.
  • min_max_age, include_subdomains, preload (HSTS only).
  • required_directives, forbidden_sources (CSP and CSP-Report-Only). A missing fetch directive falls back to default-src. A source is forbidden only when every policy allows it. 'unsafe-inline' next to a nonce, a hash or 'strict-dynamic' is not a violation, because browsers ignore it.

Safety limits

  • Only http and https URLs. No credentials in a URL.
  • localhost, and each hostname that resolves to a private or reserved address, is refused. The Actor resolves and checks each redirect hop again before it follows the hop. A redirect chain that repeats a URL stops.
  • An unusable input entry becomes one URL error record with the reason code INVALID_URL or BLOCKED_TARGET. It does not stop the run, and it is not charged.
  • One URL gets one request and one retry. One URL takes at most 2 x request_timeout_seconds + 5 seconds.
  • The webhook gets no redirect and at most 100 regressions in its body.

Output

The record_type field separates the records.

Summary (summary): url_count, urls_checked, urls_unavailable, changed_url_count, unchanged_url_count, baselines_created, change_count, changes_by_class, regression_count, regressions_by_severity, unconfirmed_regressions, policy_violation_count, fail_on, gate_failures, gate_pass, alert_status, baseline_store, baseline_name, baseline_reset and observed_at.

Change (change): url, final_url, final_status, baseline_final_status, header, baseline_value, current_value, change_class, severity, regression, fails_gate, confirmed, details (each with directive, source, change_class, severity and description), baseline_updated_at and observed_at.

Policy violation (policy_violation): url, final_url, final_status, header, current_value, severity, description, fails_gate and observed_at.

URL error (url_error): url, final_url, final_status, baseline_final_status, reason_code, error, attempts and observed_at.

Webhook body: event (regression-flagged), gate_pass, fail_on, url_count, urls_checked, changed_url_count, regression_count, regressions_by_severity, policy_violation_count, baseline_name, observed_at, regressions and regressions_truncated.

Reason codes

OK, STATUS_CHANGED, SERVER_ERROR, TIMEOUT, DNS_FAIL, CONNECT_FAIL, BLOCKED_TARGET, REDIRECT_LOOP, RUN_DEADLINE, CHARGE_LIMIT and INVALID_URL.

Pricing: pay per event

EventUnitCounted as
run-startedone Actor runCharged one time, before the Actor requests a URL.
url-checkedone URL requested and its security headers compared with the baseline in one runCharged one time for each URL that the Actor compared, or for which it wrote the first baseline. A URL that it could not check (an error, STATUS_CHANGED, CHARGE_LIMIT) and an unusable input entry are not charged. The confirmation request is not charged.
regression-flaggedone header on one URL that was removed or weakened since the baselineCharged one time for each change record with the change class removed or weakened. A regression at or above fail_on stays in the baseline, so it is charged on each run until it is fixed or reset. A change record with confirmed: false (the confirmation request got no usable answer) is charged. A regression that the confirmation request did not repeat (counted in unconfirmed_regressions, no change record) and a policy violation are not charged.

Before the Actor requests a URL, it limits the URL list to the number of url-checked events that the maximum total charge of the run allows. The URLs outside that limit get the reason code CHARGE_LIMIT, are not requested, and keep their baseline.

Local development

uv sync
uv run pytest
uv run ruff check .
uv run python scripts/stability_probe.py 3 # the stability probe, not part of the image