Unmask the internet. Real-time scam, virus and malware protection for search results, email and downloads. Brand domain: www.usesentinel.technology (configured in brand.json).
Sentinel marks risky links with three masks, each coloured by severity:
| Mask | Threat | Yellow | Orange | Red |
|---|---|---|---|---|
| Smiling mask | Scam | Suspicious | Likely scam | Confirmed scam |
| Mask with spores | Virus | Possible virus | Likely virus | Virus detected |
| Horned mask | Malware | Possible malware | Likely malware | Malware detected |
Red is only ever shown with evidence (a threat-feed match, a known scam kit on an impersonating page, a known malicious file, or unmistakable hostile behaviour). Heuristics alone top out at orange.
| Free | Pro | Max | Ultimate | |
|---|---|---|---|---|
| Price / month | $0 | $15 | $40 | $100 |
| Link scans / week | 10 (no research, scam mask only) | 40 (researched, all masks) | 100 (researched, all masks) | 500 (researched, all masks) |
| Virus & malware scans / week | 5 | 40 | 100 | 500 |
| Fast live scanning / week | 15 min | 24 h | unlimited | unlimited |
| Delicate live scanning / week (every result researched) | No | 4 h | 24 h | 96 h |
| Email masks (Gmail/Outlook) | No | ✓ | ✓ | ✓ |
| Paste-in email scans | No | No | ✓ (counts as a link scan) | ✓ (counts as a link scan) |
| Download protection (app) | No | ✓ | ✓ | ✓ |
Prices and entitlements live in server/lib/plans.js and are enforced there: the client only renders what the server returns. An uncapped allowance is stored as null.
server/ Node API + web server (zero npm dependencies, built-in SQLite)
lib/scan/ knowledge, checklist, compare, research, netguard (SSRF), filescan, email, feeds, engine
lib/ auth (scrypt, sessions, 2FA, Google OAuth), security, plans, mailer, db (migrations)
routes/ auth, account, scan/live, optional AI assistants
web/ marketing site, installable web app (/app), auth pages, partials, service worker
extension/ Browser companion (MV3): search masks, email masks, page warnings
desktop/ Sentinel desktop app (Electron): tray, download protection, unlocks live features
tests/ node:test suites with local scam/malware fixture sites
scripts/ evaluate, admin, icons, fonts, packaging
deploy/ docker-compose + Caddy (automatic HTTPS for www.usesentinel.technology)
Requires Node 22.5+.
npm start
Open http://localhost:8787. With no .env, billing runs in demo mode (plans switch instantly, no payment) and emails such as password resets are printed to the console.
npm test
npm run evaluate
npm run evaluate measures detection on today’s live phishing feed and false alarms on 127 popular sites. At last run: 0 false alarms on the 127 sites checklist-only and 0/32 with live research; 62% of live phishing addresses were flagged by the checklist alone, with no list lookup at all, and listed ones are confirmed red. Details under Testing the scanner.
The app ships the whole Sentinel server inside it and runs it privately on
127.0.0.1:47821 when it starts. Accounts, scanning, threat feeds and history
all work on a computer with nothing hosted anywhere; the database and logs live
in the user’s app-data folder (%APPDATA%\Sentinel on Windows). Set
SENTINEL_ORIGIN=https://your-sentinel.example before launching to use a hosted
Sentinel instead, and npm run dev in desktop/ to use the dev server.
Beyond running the product, the app protects on its own, with no browser add-on involved:
desktop/src/watch.js asks Windows,
through the accessibility interface screen readers use, for the address of
the page in front, where that page is on screen, and on a results page the
addresses of the links on screen and where they are. It reads addresses only:
never page text, form fields or anything typed, and it takes no screenshots.npm run evaluate:modes (every public list loaded except the one the test
addresses come from): fast flagged 291 of 300 phishing addresses (97.0%) and
left 127 of 127 legitimate sites alone, 97.9% right overall, median 41 ms an
address; delicate flagged 97 of 100 where fast flagged 96, with no false
alarms in 50, 98.0% right overall, median 162 ms. Neither is 99.9%: the
misses are addresses with nothing wrong in the name, on no list, with an
unremarkable registration, and only opening the page would tell.desktop/src/overlay.js is one transparent window laid
exactly over the page area of the browser in front. It draws the gold line and
tint when scanning starts and whenever a search comes back, the gold Sentinel
mask in the bottom right corner for as long as that browser is being watched
(it turns the verdict’s colour, with a line of words, on a dangerous page),
and a mark beside every search result: a yellow, orange or red mask, or a
quiet tick. A mark explains itself on hover. The window cannot take focus,
and every click and key goes straight through it to the browser.POST /api/v1/auth/device, loopback only, enabled only by the
desktop app), so download protection and defense are on from the first minute.desktop/src/defense.js watches Downloads, Desktop, Temp and
Startup plus Run keys and scheduled tasks. A flagged program is quarantined
at once (a move, reversible), any process running from it is ended, and the
entries that would relaunch it are removed. No kernel driver: it runs beside
the system antivirus. Malware-side tests run in the sentinel-lab
VirtualBox VM, never on a developer’s machine. Last run there (1.4.4, a
harmless program carrying the test suite’s inert marker, started and given
a Run key, a Startup shortcut and a scheduled task): process ended, file
quarantined, and all three ways back removed; an EICAR file was left to the
system antivirus and its Run key removed.logs/watch.log.desktop/src/updater.js checks GitHub Releases and downloads a
newer installer quietly (checked against the SHA-512 in latest.yml). Sentinel
runs that exact file itself, checked again just before it runs, into the folder
the running copy lives in (/D=). Until 1.6.5 electron-updater ran whatever its
cached record named, which on one computer was still the 1.5.0 installer after
1.6.4 had downloaded, so every “Restart to update” reinstalled 1.5.0. After an
update Sentinel checks it took; if not, it downloads again and says why in
logs/app.log. The uninstall step removes only Sentinel’s own files
(desktop/build/installer.nsh), never the whole folder.Pre-launch builds run billing in demo mode so every plan can be exercised.
cd desktop && npm install && npm run dist:win
That produces desktop/dist/Sentinel-Setup-<version>.exe, with the server,
site and browser companion bundled in.
Downloads come from GitHub Releases, built by .github/workflows/release.yml
with GitHub’s own token. To ship a version, bump desktop/package.json and:
git tag v1.5.0 && git push origin v1.5.0
The workflow runs the tests, builds the installer and both companion builds,
and publishes Sentinel-Setup.exe, sentinel-companion.zip,
sentinel-companion-firefox.zip, latest.yml and SHA256SUMS.txt.
latest.yml is what installed copies read to update themselves, so a release
reaches everyone who already has the app without them downloading anything.
The download page always links to the latest release and shows its real
version and size from the GitHub API. The installer is not code-signed, so
Windows SmartScreen warns on first run until a signing certificate is bought.
On Windows the app needs no add-on: live scanning above does the search marks, the page warnings and the overlay by itself, in every browser including private windows and the DuckDuckGo browser. The companion remains for people who are not on the Windows app. It is no longer bundled with the installer or offered in the app.
One codebase, two packages, built by npm run build:ext:
| Package | Browsers | Background |
|---|---|---|
sentinel-companion-latest.zip |
Chrome, Edge, Brave, Opera, Vivaldi | service worker (src/background.js) |
sentinel-companion-firefox-latest.zip |
Firefox 128+, LibreWolf | event page (src/background.firefox.js, generated) |
Every script talks to one ext namespace (browser where it exists, otherwise
chrome) and uses promises, which both engines support; tests/extension.test.js
fails the build on a bare chrome.* call or a callback. Firefox has no
externally_connectable, so pairing runs entirely through the /connect page’s
content script, and it grants host access on request, so the popup asks for it
the first time. Firefox also runs content scripts in a sandbox where
globalThis is not window, so shared objects are read from globalThis.
Sign the Firefox build with npx web-ext sign before distributing it outside
the app.
Inside the browser, the companion draws the same thing the app draws from
outside: a golden line under a faint golden tint while results are checked,
then a mark on every result (a yellow, orange or red mask exactly as the engine
returned it, or a quiet tick). Its overlay lives in a closed shadow root with
pointer-events: none, respects reduced motion, and only renders: verdicts
come from the background worker, and nothing in the page script can assign red.
Live hours are spent only when Sentinel actually checks something, and only
for a tab that is in use. On Max and Ultimate every result is researched as a
second pass; the plan gate is server/lib/plans.js.
| Browser | Companion add-on | Live scanning with no add-on (Windows app) | How the companion was checked |
|---|---|---|---|
| Chrome | yes | yes | verify-companion.js, Chrome 153: 10 of 10 results marked, overlay seen |
| Edge | yes | yes | same, Edge 153 |
| Firefox | yes (temporary add-on until signed) | yes | verify-companion-firefox.js, Firefox 156: 10 of 10 marked, overlay seen |
| Brave | yes, the Chromium package | yes | verify-companion.js in a Windows 11 virtual machine, Brave 153: 10 of 10 marked, overlay seen |
| Opera | yes, the Chromium package | yes | same, Opera (Chromium 152): 10 of 10 marked, overlay seen |
| Vivaldi | yes, the Chromium package | yes | not run; it takes the same package through the same engine |
| LibreWolf | yes, the Firefox package | yes | not run; same engine and package as Firefox |
| DuckDuckGo browser | no | yes (Windows) | it has no extension support at all, on any platform |
npm run verify:companion loads the built packages into real browsers,
headless, in throwaway profiles (DevTools pipe and Extensions.loadUnpacked
for Chromium, Marionette for Firefox), pairs them with the running desktop app,
and checks a results page end to end. The page is served locally at the search
engine’s address; its links are honest sites and made-up scam-style addresses,
and none of them is ever opened.
npm run build:static exports web/ to dist-static/ for hosts with no Node
server (GitHub Pages and the like). It expands the same @include partials the
server renders, rewrites root-absolute links so the site works from a subpath,
and bakes today’s demo verdicts in, so the search demo, mask explorer and
Spot-the-scam game still run against real scan results. Anything that needs the
API - sign-in, live scans, the app - links back to the full site instead.
The public site is served from wyattbombara/sentinel (GitHub Pages at
wyattbombara.github.io/sentinel). That repository only ever holds build output:
npm run publish:site
builds web/ for that address and pushes one commit there, using the git
credentials already on this machine. .github/workflows/pages.yml also
publishes a copy to this repository’s own Pages site on every push that touches web/.
Turn it on once under Settings -> Pages -> Source: GitHub Actions. Keeping
the static copy as a build output is what stops it drifting from the real site.
cd deploy && docker compose up -d --build
Before that: point DNS for usesentinel.technology and www.usesentinel.technology at the server, copy .env.example to .env, and set SESSION_SECRET. Optional: GOOGLE_CLIENT_ID/SECRET, SAFE_BROWSING_API_KEY, RESEND_API_KEY (password-reset emails).
last_seen is written
at most once a minute), /auth/me re-issues the cookie so the browser’s
30 days slide too, and an hourly sweep plus a check on use end sessions that
have sat idle. Not ticked: a session cookie with no lifetime, dropped when
the browser closes, and a 12-hour idle limit on the server. Only an explicit
true counts. The paired app and companion use their own tokens, which end
after 90 idle days.sentinel.db holds
accounts, sessions, history and settings. The threat lists, more than a
million rows rewritten every few hours, live in sentinel-feeds.db, a cache
that is deleted and downloaded again if it is ever damaged. The accounts file
gets an integrity check on every start: damaged indexes are rebuilt, rows that
point at accounts which no longer exist are removed, and a file that cannot
be repaired is set aside (never deleted) while the last verified backup is
restored. Backups are taken at start and every six hours. One server per
database: a second one refuses to start rather than share the file. The
claim (server/lib/dblock.js) is taken before the file is opened and
records when its process and the computer started, so a claim left by a
killed server is recognised as stale even when Windows has since given its
process number to another program. A bare process number got that wrong and
could stop the scanner from ever starting again after a restart. A file
left far larger than what it holds (the threat lists used to live in it, and
the desktop app stops its server without a clean close) is shrunk on start.scrypt password hashing · account lockout · TOTP two-factor with replay protection · session list with per-session revoke · rolling session expiry · password reset with single-use expiring links · HttpOnly/__Host- cookies · CSRF origin checks + JSON-only bodies · strict CSP, HSTS, COOP/CORP, frame denial · per-IP and per-account rate limits · SSRF-safe research fetcher (private/metadata IPs blocked, DNS pinned per hop) · path-traversal-safe static server · AES-256-GCM for stored secrets · audit log · no admin HTTP surface (npm run admin) · extension message-origin validation · desktop IPC origin checks, sandboxed renderer.
Operator commands:
node scripts/admin.js set-plan user@example.com max
Every scan starts by looking the address up in 15 free, keyless public lists
(server/lib/scan/feeds.js): OpenPhish, PhishTank, Phishing.Database, the
malware-filter phishing and URLhaus lists, CERT Polska, DurableNapkin,
Spam404, MetaMask, ScamSniffer, Polkadot.js, URLhaus, ThreatFox, Feodo Tracker
and Blackbook. A hit in any of them is evidence, and evidence means a
confirmed (red) verdict rather than a guess. A hit is: the exact address is
listed, the domain is listed, or the site’s own front page is listed.
A different page on a host that serves a listed address is an inference, not
a listing, and it is treated as one: the verdict is “Likely” (orange) and says
so in words (“Another page on this site is listed by PhishTank; this address
is not”). It becomes red only when the address also fails checks of its own,
which is what separates a throwaway host from a large service where one tenant
misbehaved. Before 1.4.3 any such page was red, and with the lists loaded that
made app.hubspot.com/login a confirmed scam because of one PhishTank entry
elsewhere on the host. Services that carry other people’s pages (form
builders, site builders, mail-tracking links, document shares; the lists
themselves show which: new.express.adobe.com alone has over a thousand
listed pages) are judged page by page. Each list refreshes on its own interval, and a verdict says how many
of them had loaded when it was made.
When nothing is listed, the name is still compared with at least ten known
scam domains by edit distance and shared distinctive tokens
(server/lib/scan/compare.js), and the result reports how many it was
measured against.
Three layers, none of which download or run malware:
npm test runs the fixture suite (tests/): a local fixture server serves fake scam kits, disguised files and an EICAR-style known-bad sample, so every rule is exercised against pages that never leave this machine.npm run probe checks that the address rules generalise: 76 made-up scam-style addresses that appear in no feed (fake logins, parcel fees, tech-support alerts, prize bait, crypto giveaways, clearance stores) (walletconnect-dapp-sync.app, portal-hr-payroll.net/adp/login, x7k29q.cloudfront.net/login.html, a login page under /.well-known/…) against 110 real sites that use the same words legitimately (support.com, wallet.com, verify.gov, password.com among them) (walletconnect.com, www.adp.com/logins.aspx, outlook.live.com/owa/, www.dropbox.com/login…). Research is off, so nothing is fetched. Last run: 75 of 76 flagged, 0 of 110 false alarms, both with the threat lists empty (the worst case, a fresh install) and with them loaded (about 430,000 listed hosts and addresses); the one miss is the bucket case listed under Known limits. Run it both ways: the loaded run is the one that caught the false red described under Threat lists.npm run evaluate downloads the current OpenPhish list (addresses only; no phishing page is opened) and judges each address by the checklist alone, with no list lookup and no research. Last run: 185 of 300 flagged (61.7%: 136 yellow, 49 orange, 0 red; it was 44.3% before the rules added since 1.4.3, each of which came from reading what that run missed and counting the public lists), 0 of 127 legitimate sites flagged, 0 of 32 when researched. In normal use every one of those 300 is on a list and comes back red; this is what the address rules manage unaided.tests/auth.test.js proves accounts are durable: sign-in after the server process is killed and restarted, the 30-day rolling window, the session-only cookie, expiry after 30 idle days, duplicate email refused, per-session revoke, and the database repairing itself.npm run verify:companion drives real browsers (see Browser support).What was deliberately not done: no confirmed-malicious URL was fetched, no malware sample was downloaded or placed on disk, and no protection was disabled to make a test pass.
brand.json drives the origin the extension and hosted app will talk to once www.usesentinel.technology resolves.BILLING_MODE=disabled (upgrade buttons explain plans are coming). Stripe or similar must be added before charging.RESEARCH_ENABLED=0, so a person’s computer never opens a suspicious page; scans say so in place of the research section. Research needs the hosted service.RESEND_API_KEY the server records the account and tells the person verification is unavailable; it never pretends to have sent anything.logs/watch.log and the Live panel say what was watched and how many results were marked.--load-extension is ignored by branded Chrome, so the app opens chrome://extensions for “Load unpacked” instead, and the test harness uses the DevTools pipe.storage.googleapis.com/x/index.html with no login wording in the address gets 22 points from the address alone; the page has to be fetched (research on) for the form and script checks to add to that.