# Changelog of Exploit Radar — Website Attack Surface Monitor (`0xgollum/exploit-radar`) Actor

- **URL**: https://apify.com/0xgollum/exploit-radar/changelog.md
- **Full Actor documentation**: https://apify.com/0xgollum/exploit-radar.md

## Changelog

### 0.1.9 — Deep review (09/09/2026): fewer false alarms, no phantom bills

Every dataset item on this actor is a billed $0.25 exposure, so a false
finding is not just noise — it is an invoice. This pass targeted exactly that.

**Fixed — billing**

- **A quiet run no longer produces a billed row.** 0.1.8 pushed a `status`
  row to the dataset when nothing was new; on this actor that row cost $0.25
  per quiet run. The outcome of a quiet run is now written to the run's
  key-value store (`LAST_RUN_STATUS`) — visible, free.
- **A domain that could not be checked at all is no longer charged** the
  `domain-scanned` event.

**Fixed — false positives (each one was a billed CRITICAL/HIGH)**

- **CVE matching now uses NVD's CPE match, not keyword search.** Keyword
  search returns any CVE whose text contains the words: measured live,
  "WordPress 6.4.2" returned 11 CVEs of which 5 were *plugin* flaws fixed in
  a 6.4.2 of that plugin — reported as CRITICAL against a site that is not
  affected. Known products (WordPress, Drupal, Joomla, TYPO3, nginx, Apache,
  jQuery, Bootstrap, lodash, …) and WordPress plugins are now matched by CPE,
  which is version-range aware: 4 real CVEs for WordPress 6.4.2, and 6 for
  nginx 1.18.0 where keyword search found none. Unknown products fall back
  to keyword search filtered to CVEs that are about that product and not
  already fixed in the detected version. Set `NVD_API_KEY` as an environment
  variable to lift NVD's public rate limit.
- **Sensitive-file probes need the file to look like the file.** A site that
  stamps a CSRF nonce or timestamp into every page defeated the soft-404
  fingerprint and made all 11 probed paths read as exposed (measured 11/11).
  `/.env` must contain `KEY=value` lines, `/.git/config` a `[core]` section,
  `/backup.sql` SQL, `/backup.zip` a zip header, and so on; an HTML page is
  never a config file.
- **Plain HTTP answered with 4xx/5xx is not "plain HTTP served".** A 403 on
  port 80 means HTTP is refused, which is fine; only a 2xx is flagged.
- **A public S3 bucket named after the domain is reported as HIGH, not
  CRITICAL,** with a note to verify ownership — bucket names are global and
  it may be someone else's.
- **Shodan ports/vulns are skipped for CDN-fronted sites.** Behind
  Cloudflare/Fastly/Akamai/CloudFront the public IP is the CDN's, so its open
  ports and vulns are the CDN's too; they were being attributed to the client.

**Fixed — false negatives**

- **Expired, self-signed and wrong-host certificates are now named** as
  `tls_cert_expired` (CRITICAL), `tls_cert_self_signed`, `tls_cert_hostname_mismatch`
  instead of one vague `tls_handshake_failed`; the expiry branch was
  unreachable for the certificates it was written for. Nothing on 443 is
  `https_unreachable` (MEDIUM).
- **Cookies set on a redirect hop are checked.** The session cookie is very
  often set on the `/ → /home` redirect; only the final response was read.
- **An unavailable intel source (NVD rate limit, Certspotter, Shodan
  InternetDB, ransomware.live) now marks the scan incomplete** instead of
  reading as "nothing found" — which, combined with 0.1.8's pruning, would
  have dropped every finding from that source and re-alerted them all on
  the next successful run.

### 0.1.8 — Reliability pass (portfolio-wide sweep, 09/09/2026)

Three defects found by reviewing this actor against the bug classes that had
just surfaced in Malicious Package Watch. All three affect `watch`-style
scheduled use — which is how the actor is meant to run.

**Fixed**

- **A quiet run was counted as a failed run.** When no exposure was new since
  the last check, the actor pushed nothing; Apify counts a run with zero
  dataset items as failed, which is why the Store showed a 0 % success rate
  on a client's perfectly healthy domains. A quiet run now pushes one
  `category: "status"` row (`finding: "status:no-new-exposures"`).
- **An incomplete scan overwrote the domain's state.** If the homepage was
  briefly unreachable or any check raised, the (partial or empty) result was
  stored as the new baseline, so every finding of a skipped check came back as
  *new* on the following run. The check runner now reports whether the scan
  was complete; an incomplete scan **merges** into the stored state instead of
  replacing it, and a first scan that is incomplete defers its baseline.
- **Exposures held back by `max_results` were lost.** State was written before
  the cap was applied, so anything cut was marked as already reported and never
  came back. State is now written after `push_data`, only for the rows it
  actually accepted; held-back exposures return on the next run, and the log
  says how many were held back.

**Changed**

- Findings are sorted severity-first across *all* domains before the cap is
  applied (previously only within each domain).
- `checked_at` is a full UTC timestamp.
