Hostveil / Docs / Scoring

Scoring

Hostveil merges every finding into one 0–100 number. This page is the whole model: what the number measures, the arithmetic that produces it, why that arithmetic and not the obvious one, and the questions it cannot answer. Every figure below is exact, and the worked example is a real scan.

What the score measures

The score measures configuration hardening on the domains that could be examined. It reads what your sshd_config, your Compose files, your firewall, your unit files and your kernel say, and grades the distance between that and a defensible setup. It is a measurement of your decisions, not of your luck.

Three things it is deliberately not.

It is not a risk score. Risk needs to know what a compromise would cost you, and a scanner reading files cannot know that. A privileged container is the same finding whether it serves a photo gallery or your accounting records. Hostveil grades urgency — how much stands between an attacker and the problem right now — and stops there.

It is not a comparison between hosts. Two hosts are frequently scored over different denominators, because a domain that could not run leaves the total entirely. A host with no Docker is not scored on container exposure, the Docker daemon, or image CVEs; its 88 and your 88 are averages over different sets of axes. Comparing them is comparing two different questions.

It is not a certification. Hostveil checks the paths that get self-hosters breached, which is a curated list and not an exhaustive one. A perfect score means the checks that ran found nothing material — it does not mean there is nothing to find.

What it is good for is one host over time, and the axis breakdown underneath it. The headline number tells you whether last week’s work moved anything; the per-axis scores tell you what to do next.

What a score is called

Every interface completes the sentence “This host is …” from the overall score. There are four bands and the thresholds are fixed. The lower bound is inclusive, so a score of exactly 80 is in good shape.

ScoreBandThe sentence you see
80–100goodThis host is in good shape.
50–79fairThis host is middling.
25–49poorThis host is exposed.
0–24criticalThis host is wide open.

The band decides the colour of the meter as well as the wording, in all three interfaces. A host with nothing scannable shows N/A and no band at all, because there is no score to classify.

Bands run the opposite way to severities, which is worth saying once. A high score is a good host; a high severity is a bad finding.

The formula

Every detection domain gets one axis. An axis starts at 100 with nothing against it, and each finding on it takes a share of what is left:

remaining = 1.0
for each finding on this axis:
    nth = how many findings with this ID came before it, + 1
    remaining *= 1 - weight(finding) / nth

score   = round(100 × remaining)
penalty = round(cap × (1 - remaining))

The weight comes from severity, and only severity — see Findings with no fix weigh the same as anything else for why an unpatched vulnerability gets no special treatment:

SeverityWhat it claims about your hostTakesDecimal
HIGHReachable right now, from off the host, by someone holding nothing.1/2 of what remains0.5
MEDIUMGives way to a foothold, a guessed credential, or a local account.1/80.125
LOWNo known path today; it narrows what a future compromise reaches.1/160.0625

The anchor is the first row: one High finding costs exactly half of whatever the axis has left. The other two levels are defined against that and have no independent meaning. The 4× gap between High and Medium is the point of the taxonomy rather than an accident of it. The names are the familiar three, but there are only three: the scale this replaced had a fourth level above High, and its top two were 8 and 5 — which is not a gap you can feel. Merging them is what buys the gap.

Four consequences worth having in mind when you read an axis.

The same mistake made twice is worse than once, and not twice as bad. A compose file where four services are all missing user: is one line missing, written four times — not four independent risks. So the second instance of a finding ID costs half its weight, the third a third, the fourth a quarter. Without that, an axis was buried for running more services rather than worse ones: a self-hoster deploys twenty containers from one template, and one missing line pinned the axis at zero. It is not free after the first either — the tenth instance says the mistake is systematic, which is worse than the first, just not ten times worse.

The same finding costs more on a healthy axis than on a damaged one. A High finding on an untouched axis costs 50 points. The identical finding on an axis already down to remaining = 0.25 costs 12.5. That is the model working: the second time your SSH daemon hands out unauthenticated root, it is not twice as bad.

An axis never actually reaches zero. The largest weight is 1/2, so every factor is at least 1/2 and the product stays above zero forever. Eight High findings run an axis down 100 → 50 → 25 → 13 → 6 → 3 → 2 → 1 → 0; the displayed 0 at the eighth is round(0.390625), not arithmetic exhaustion. A ninth finding still shrinks remaining, and removing findings from an axis pinned at 0 lifts it back off in a way that does not look linear.

Order does not matter. Multiplication commutes, so the order findings arrive in cannot change the product. The damping for repeats would put that at risk on its own — one ID can carry two severities, and whichever instance was seen first would pay full price — so within an ID the heaviest instance is sorted to the front and pays it. The factors are then the same sequence whatever order the findings came in, which also makes the arithmetic bit-identical: the severity weights are dyadic fractions (1/2, 1/8, 1/16) and exact in binary, while the divisors for repeats (1/3, 1/6, …) are not, and a fixed order is what stops that difference from being visible.

An axis can never read 100 once anything lands on it. The smallest weight in the model is 1/64, so even a lone unpatchable Low finding puts an untouched axis at round(98.4375) = 98.

Score and penalty are two different numbers

Each axis reports both, and they are rounded independently from the same remaining. The score is the axis on its own terms, 0–100. The penalty is what that axis contributed to the headline number, expressed in the axis’s own cap.

The score is round(100 × remaining) and not 100 - penalty × 100 / cap, for resolution: a cap-6 axis has only seven possible integer penalties, so deriving the score from the penalty would give that axis seven distinct scores. Drawing from remaining gives every axis the full range no matter how small its cap.

The visible consequence is that the displayed penalties do not have to add up to 100 - overall, and this is not a bug. With all thirteen domains running and exactly one Low finding on File permissions (cap 5), one on Kernel hardening (cap 5), and one on Service hardening (cap 6):

AxisCapremainingScorePenalty
File permissions50.937594round(0.3125) = 0
Kernel hardening50.937594round(0.3125) = 0
Service hardening60.937594round(0.375) = 0

Three axes reporting a penalty of 0, and an overall of 99 — because the overall sums the unrounded contributions (0.3125 + 0.3125 + 0.375 = 1.0) and rounds once at the end. Rounding each axis first would have made those three findings free, which is exactly the failure the next section is about, arriving by a smaller door.

Rounding, precisely

Everywhere in the model, rounding is half away from zero — 12.5 becomes 13, 1.5 becomes 2. Not banker’s rounding. The two levels round different quantities, so ties break in different directions: an axis rounds the share it kept, so ties go in your favour, while the overall rounds the share you lost and subtracts, so ties go against you. An SSH Low finding (14 × 0.0625 = 0.875) plus a CVE Low finding (10 × 0.0625 = 0.625) total exactly 1.5, which rounds to 2 — overall 98, where rounding the kept share would have given 99.

What counts as one finding

Before a finding erodes anything, three guards run in order. The third is invisible on a healthy build and worth knowing about anyway: a finding from a domain with no scoring axis is dropped, because there is nothing for it to erode.

Fixed findings are skipped entirely. Apply a fix and the number moves immediately, before any rescan — no erosion, and it drops out of the per-severity counts too.

Duplicates collapse. A finding’s identity is source|id|service, so the same rule firing on the same service twice is scored once. The same rule on different services is two findings and erodes twice, which is the normal case for Compose rules across a dozen containers — compose.ds008 on eleven services is eleven findings, and it should be. Because the source is part of the identity, deduplication can never reach across axes.

A Fixed finding does not claim its identity on the way past, so it can never suppress an identical unfixed one regardless of what order they arrive in. And the per-severity counts an axis reports are incremented in the same guarded step as the erosion, which means every finding in those counts moved the axis — if it says 2 high, 1 medium, 2 low, those five each took a share. The guarantee runs one way only: a severity this build does not recognise erodes the axis and matches no count slot, so an axis can move with every count at zero.

No finding is ever free

A severity this build does not recognise — a level added to the enum that this code path was never taught about — is priced as MEDIUM, not as zero. (It cannot arrive from a file: reading a scan snapshot rejects an unknown severity outright rather than guessing.) The failure mode of a scoring model is a finding that quietly costs nothing, and defaulting to the middle of the scale is the cheap way to never have one.

Why multiplicative, and not additive

The obvious model is to give each severity a number of points, add them up per axis, and clamp at the axis’s weight. Hostveil did that, and it produced a score that stopped responding to the host.

Under 8 / 2 / 1 points and a cap-14 axis, two High findings sum to 16, clamp at 14, and the axis is at 0. Every finding after that is free. The third, the fourth and the fiftieth cost nothing, because there is nothing left to take. A host with 27 container findings scored identically to a host with 3, and both the CVE and container axes sat pinned at 0 on essentially every real machine.

That is bad measurement, but the worse half is what it teaches. An operator who fixes two of their five SSH findings watches the number not move, because the axis was clamped before their work started and is still clamped after it. The tool tells them the work was pointless. A hardening score whose response to hardening is nothing is not a score.

Multiplicative erosion has no clamp to hit. Each finding takes a share of what remains, so the axis asymptotes toward zero and never arrives — the fiftieth finding still costs something, and removing any one finding moves the number back. The cost of a finding falls as the axis degrades, which is the right shape: the marginal danger of the tenth exposed service genuinely is lower than the first. But it never reaches zero, so the model never stops listening.

A worked example

A real scan of a deliberately messy host: 83 findings, scan exiting 1. Twelve domains were attempted and ten ran — Trivy was not installed and systemd did not answer. One of the ten, the Docker daemon, covered only part of its ground, so the 83 findings come from ten domains of which nine were complete.

This capture is from before the Reverse proxy axis

Twelve domains, and the caps of the release it was taken on — so the numbers in this section no longer match the table above, and are left alone rather than restated at today’s caps. The reason is worth stating plainly, because it is a gap in how this page is maintained rather than a property of the model: the exact contributions here were derived by hand, the scan they came from is not kept anywhere, and ScoreAxis reports only a rounded score and an integer penalty — so nothing in the repository can reproduce or check them. Restating them at new caps would mean re-deriving figures nobody can verify, which is the opposite of what this section is for. What it teaches is unaffected: a cap enters only at the last step, scaling a remaining that is computed without it, and every figure below is still exact for the scan it came from.

One axis, step by step

SSH completed, and reported five findings:

FindingSeverityweight1 − weightremaining
(start)1
ssh.emptypasswordsHIGH1/21/21/2 = 0.5
ssh.rootloginHIGH1/21/21/4 = 0.25
ssh.passwordauthMEDIUM1/87/87/32 = 0.21875
ssh.logingracetimeLOW1/1615/16105/512 = 0.205078125
ssh.maxauthtriesLOW1/1615/161575/8192 = 0.1922607421875

The decimals are exact, not rounded — every factor is a dyadic fraction. The table reads top to bottom, but the result does not depend on that: reorder the five rows and the product is unchanged.

score   = round(100 × 0.1922607421875)  = round(19.226074…)  = 19
lost    = 1 - 1575/8192 = 0.8077392578125
penalty = round(14 × 0.8077392578125)   = round(11.308349…)  = 11

The axis reports 19 with a penalty of 11 against its cap of 14. Note that 11/14 would have given a score of 21 — the score comes from remaining, not from the rounded penalty.

Twelve axes, and the overall

AxisCapStateScorePenaltyContribution (cap × lost)
Container exposure14Done01413.998940
SSH hardening14Done191111.308350
Host firewall9Done5054.500000
Auto-updates7Done4443.937500
Vulnerabilities10SkippedN/Aexcluded
Exposed services8Done1277.062500
Account hygiene7Done2555.250000
File permissions5Done10000.000000
AI agent runtimes8Done187.952148
Kernel hardening5Done6821.620417
Docker daemon7Partial7721.616699
Service hardening6SkippedN/Aexcluded
ranCapSum = 100 - 10 - 6 = 84
total     = 57.246554

overall   = 100 - round(57.246554 × 100 / 84)
          = 100 - round(68.150660)
          = 100 - 68
          = 32

Two things in that table are worth pausing on. The Docker daemon axis is Partial — it covered some of its ground and not all of it — so it is scored normally and flagged, because a domain that saw half your daemon still saw half your daemon. And the two skipped domains take their caps out of the denominator rather than scoring 100. Had they been scored 100 instead, they would have added 0 penalty over a denominator of 100, and the host would read 43: an 11-point bonus for the two things nobody could look at.

The same host, scored additively

Take the same 83 findings, sum the severity points per axis, clamp at the cap. SSH, finding by finding:

AfterAddedRunning sumClamped (cap 14)Additive scoreReal score
ssh.emptypasswords+8884350
ssh.rootlogin+81614025
ssh.passwordauth+21814022
ssh.logingracetime+11914021
ssh.maxauthtries+12014019

The axis bottoms out at the second finding. Three of the five findings on this daemon are free, and the score cannot distinguish an SSH server with two problems from one with five. Now watch what each model does when you go and fix something:

You fix…AdditiveMultiplicative
both LOW findings0 → 0 (sum is still 18, still clamps)19 → 22
both HIGH findings0 → 7119 → 77

Across the whole host, the additive model reads:

AxisCapΣ severityClampedAdditive scoreReal score
Container exposure141241400
SSH hardening142014019
Host firewall9881150
Auto-updates7107044
Exposed services8258012
Account hygiene7167025
File permissions500100100
AI agent runtimes860801
Kernel hardening565068
Docker daemon7444377

Additive overall: 100 - round(75 × 100 / 84) = 11, against the real 32. Seven of the ten applicable axes read exactly 0 — including Kernel hardening, which has one Medium finding and four Low findings and genuinely scores 68. A host where seven axes are all “0” has no next step to offer you.

The container axis is the cleanest statement of the point. It carries 52 findings — 8 High, 16 Medium, 28 Low. Additively, the sum passes the cap after two of them and the other 50 are free. Multiplicatively, remaining is 7.5698 × 10⁻⁵, which displays as 0 but is not 0: every one of the 52 findings moved it, and removing any single one moves it back.

A cap is a weight, never a threshold

Each axis carries a cap, and the thirteen caps sum to exactly 100. The sum is a readability decision rather than an arithmetic requirement: renormalization would work with any total, but a total of 100 is what makes an axis’s penalty readable as points off the headline, and makes the overall exactly 100 − round(total) on a host where every domain ran.

A cap is the axis’s share of the overall score and nothing else. It is purely how much this domain matters. In particular:

The cap is not a budget of damage the axis can absorb before it fails. It does not appear in the per-axis score at all. Every axis is scored 0–100 on its own terms, by the same erosion. A cap-5 axis and a cap-14 axis with identical findings show the identical axis score.

The cap enters exactly once, at the aggregate, as cap × (1 - remaining). So Kernel hardening at 40/100 drags the overall about a third as hard as Container exposure at 40/100 — not because kernel findings are less true, but because that domain was argued to matter less to a self-hosted server.

So a small cap does not mean a small problem on your host. The axis score is the honest read of that domain; the cap is only the editorial weighting on top of it. If Kernel hardening reads 12, you have a kernel-hardening problem, whatever the headline says.

What each domain is worth, and why

The arguments below are relative. They explain ties, gaps and transfers, because that is the only kind of argument available: there is no derivation that produces 14 rather than 13 for a domain in isolation. Several caps have no positive argument for their exact number and are marked as such — what they have is a defended position relative to their neighbours.

AxisCapThe argument
Container exposure13No positive argument for 13. It is one of only two axes large enough that giving a point away does not change what it can express. It used to be priced to cover the daemon as well, because it was the only place any Docker risk landed; it no longer has to, and its findings are now strictly about what a service declares.
SSH hardening13No positive argument for 13. The other axis large enough to give from the top without losing resolution, and the benchmark for remote-administration blast radius that the Docker daemon axis is argued against.
Vulnerabilities9No positive argument for 9. Characterised as already generous, and Docker-conditional like the daemon axis but slower-moving: an open API hands over every container today, while an image’s patch level is a slower problem.
Host firewall8No positive argument for 8. Already had a share argued as generous, and firewall.docker-bypass already says the daemon walks straight through it.
Exposed services7No positive argument for 7. It is the anchor the AI-agent tie is argued from, and it cannot judge the two most dangerous ports a self-hosted machine publishes: the number alone does not say whether the Docker API demands TLS verification, nor what the thing answering on 443 does with what it is handed.
AI agent runtimes7Ties with Exposed services deliberately, and the tie is the argument. Both describe a network service that should not be reachable. The agent’s worst case is strictly worse — an unauthenticated gateway is remote code execution as a real user — but it applies to far fewer hosts and is excluded entirely where no agent runtime is installed, so a generous cap costs a typical host nothing and carries real weight where it applies.
Account hygiene7An anchor. A small rule set where one member is catastrophic and the rest are hygiene; it is excluded only where the user databases cannot be read at all.
Auto-updates7An anchor, and one of the two the Docker daemon axis deliberately ties with.
Docker daemon7Ties with Account hygiene and Auto-updates deliberately, and the tie is the argument. Like account hygiene it is a small rule set where one member is catastrophic and the rest are hygiene; like both, it is excluded entirely on a host it does not apply to.
Service hardening6Sits one step under that 7-band, and the gap is the argument. It covers the same ground for a unit-started service that Container exposure covers for a Compose one — can it gain privileges, can it write outside its own data, can it read every home directory — so on a host running services natively it does that axis’s job. It sits lower because none of its findings is by itself an entry: each widens what a compromise reaches rather than granting it.
Reverse proxy6Ties with Service hardening deliberately. It is the component this audience is most likely to have and least likely to have configured, and everything else here sits behind whatever answers on 443 — which argues for more. Three things hold it to 6. Its rule set is deliberately narrow: three findings chosen for being unambiguous, with a proxy serving plain HTTP, missing security headers and anything about certificates all declined because a correct configuration and a broken one are not distinguishable from the file. It is excluded entirely on a host running no proxy, like the 7-band axes it sits under. And like Service hardening, none of its findings is by itself the way in — an insecure Traefik dashboard enumerates the backends rather than opening them.
File permissions5Ties with Kernel hardening deliberately. Both are quiet local-hardening backstops: neither is the hole an attacker comes in through, each is what stops a foothold from becoming root.
Kernel hardening5The same argument, the same sentence, the same number.
Total100

Three new axes, paid for out of the old ones

The total has to stay 100, so adding a domain is always a transfer and never an inflation. Each recent axis was funded by naming who gave and why. This is the part of the table with the most argument in it, because a transfer has to be defended twice — once for the receiver and once for each giver.

Docker daemon (7), from six axes:

GiverGaveWhy
Container exposure2It was the only place any Docker risk landed and was implicitly priced to cover the daemon too. It no longer has to.
SSH hardening1On a host running Docker, the daemon socket is a second remote-administration channel — SSH’s blast radius, none of its hardening.
Vulnerabilities1Both are Docker-conditional, and an open API hands over every container today while an image’s patch level is a slower problem.
Host firewall1firewall.docker-bypass already says the daemon walks through the host firewall.
Exposed services1The daemon axis takes custody of the most dangerous port a Docker host can publish — one the port table could never have judged.
AI agent runtimes1Its generous cap was argued from being N/A on most hosts; that argument now has a second claimant.

Service hardening (6), one point from each of six axes:

GiverGaveWhy
Container exposure1Large enough that a point does not change what it can express — gave from the top.
SSH hardening1Same reason: large enough that a point costs it nothing expressively.
Vulnerabilities1Already generous — and an unpatched image ends at a process; this axis is about what that process can then touch.
Exposed services1Already generous — the exposed port ends at a process.
Host firewall1Already generous — the bypassed firewall ends at a process.
AI agent runtimes1Already generous — the same “step after” argument.

Those last four share one argument: a service running unsandboxed is the step after every one of them. The exposed port, the unpatched image and the bypassed firewall all end at a process, and this axis is about what that process can then touch.

Reverse proxy (6), one point from each of the same six axes:

GiverGaveWhy
Container exposure1Gave from the top again, and for a second reason: on the hosts this domain reads, the proxy is a Compose service, and what its command line turns on was being scored only as a container until now.
SSH hardening1Same reason as before: large enough that a point costs it nothing expressively.
Vulnerabilities1Already generous, and the two are the same shape of claim about the same software — one about the version of what is listening, one about how it was configured. The configuration is the half the operator chose.
Exposed services1The same custody argument the Docker daemon axis made. The port table can say 443 is open, which on a self-hosted machine is the intended state and therefore not a finding; this axis is the first thing that can judge what is answering on it.
Host firewall1A firewall’s job ends at deciding what may reach the port. Everything this axis reports is on the far side of a port the operator opened on purpose.
AI agent runtimes1Its generous cap was argued from being N/A on most hosts, and that argument now has a third claimant: a host with no nginx and no Traefik is excluded from this axis exactly as it is from that one.

The arithmetic those three tables imply: before all three transfers, Container exposure stood at 17 and SSH at 16, and Vulnerabilities, Exposed services, Host firewall and AI agent runtimes each stood three points higher than they do now.

Two of those givers gave the same point twice, and the 7-band is where it shows: Exposed services and AI agent runtimes have now both come down from 8 and are still tied, which was the constraint the transfer was fitted to rather than a result of it. The tie is argued above and is not something a new axis is allowed to break in passing.

Renormalization: what happens when a domain does not run

“I could not look” and “there was nothing there” produce the same empty list of findings and mean opposite things. The whole point of renormalization is to keep them apart in the arithmetic, not only in the prose.

A domain contributes to the overall if and only if it ran, and exactly two states count as having run: Done and Partial. Pending, Skipped and Failed do not — and a domain with no recorded state at all does not either, because “no entry” is not evidence that a checker finished.

StateCounts as run?Effect on the overall
DoneYesScored; its cap is in the denominator.
PartialYesScored on what was seen, cap in the denominator, and flagged in every UI.
SkippedNoN/A. Its cap leaves both the numerator and the denominator.
FailedNoN/A, same as Skipped — excluded, not scored 0.
Pending / no entryNoN/A.
ranCapSum = Σ cap        over axes that ran
total     = Σ cap × lost over axes that ran

overall   = 100 - round(total × 100 / ranCapSum)

With all thirteen domains running, ranCapSum is 100 and the overall is 100 - round(total). With Trivy missing, it is 91, and the other twelve axes are renormalized to fill the gap — you are neither penalized for not running Trivy nor handed a free 9 points for it.

Partial is scored, and always flagged. A domain that covered part of its ground has real findings and a real, if incomplete, picture; excluding it would throw away what it did see. But an unlabelled score drawn from half a domain is the same lie as scoring a skipped domain 100, so the flag is not optional — the TUI writes 42~, the dashboard writes it in amber.

When nothing ran at all

If every domain was skipped or failed there is no denominator, and renormalizing over zero caps would fall out at a clean 100 — a host nobody could look at, reported as flawless. Hostveil refuses the computation instead of performing it: a separate applicable flag is false when no axis ran, and all three UIs render N/A rather than a number. The value left behind is 0 and it means nothing; do not read it. An overall of 0 is either a genuinely terrible host or this placeholder, and the flag is the only thing that tells them apart.

Findings with no fix weigh the same as anything else

A finding marked Unavailable — a vulnerability nobody has published a patch for — takes exactly the share its severity says, with no adjustment:

SeverityTakes
HIGH1/2
MEDIUM1/8
LOW1/16

An earlier version of this model discounted these findings by dividing their weight by 4, on the argument that every Debian-based image ships CVEs with no upstream fix, so charging them in full pins the Vulnerabilities axis near 0 for a host that pulls every image the day it is published. That argument is still true, and the discount was removed anyway: unlike one High costing exactly half or the harmonic damping for repeats, the divisor 4 had no derivation behind it — it was a number someone picked, not one the rest of the model implies. The consequence is accepted rather than solved: a host with even one un-patchable CVE may never reach 100 on the Vulnerabilities axis, because the risk is exactly as real whether or not a patch happens to exist for it yet.

Unavailable is not the same as Manual

Both are charged in full now, so the distinction is about what “unfixed” means rather than about weight. Manual findings — the ones Hostveil explains rather than fixes, like compose.ds016 or every dockerd. finding — mean no safe automation exists; you can still go and fix it yourself. Unavailable means no fix exists anywhere — there is nothing to go and do. Hostveil keeps them as separate states so the reason a finding is still open is never lost, even though neither one moves the score any differently from the other.

What the score does not tell you

The honest limits, including the ones that look like bugs and are not.

100 does not mean no findings. One Low finding on a cap-5 axis with all domains running contributes 5 × 0.0625 = 0.3125, which rounds to 0 loss. The overall reads 100 with a finding on screen. Read it as nothing material is outstanding, never as nothing was found — and read the axis breakdown, which will show that axis at 94.

The second number: “after fixes”

Beside the score, Hostveil shows what it would become if you applied every fix it offers — the Auto ones and the Review ones both. On the axis breakdown the same figure appears per axis, so you can see where the headroom is.

It exists because the score alone answers a question nobody asked. Run fix --all on a container-heavy host and the container axis may still read 2. Nothing went wrong: the findings still standing are Manual by design — a Docker socket mounted into Portainer, a second UID 0 account, host networking — and each is declined for a reason Hostveil will tell you. But a number that barely moved reads as a tool that did nothing, and that reading is wrong. The second number says which it is.

The gap to 100 is not a ceiling. It is what Hostveil will not do unattended, which is a different thing from what cannot be done. You can delete that account or drop that socket mount this afternoon, and the score will follow you when you do.

The alternative was to charge Manual findings less, and it is the wrong trade in exactly the place it looks tempting. Manual means Hostveil will not touch it, not that the risk is smaller — you can still go and remove it yourself. Discounting a risk you are perfectly able to remove would be the flattery this model was rebuilt to refuse, and it is the same reason an unpatched vulnerability is not discounted either (see above): being unable to fix something today does not make the risk any smaller.

Where nothing is fixable the figure equals the score, and every interface then shows nothing at all rather than an arrow pointing back at where it started.

An axis at 0 is not a floor. It is a display value; remaining is still positive and still moving. Fixing findings on a 0 axis does work, and the number will start climbing once enough of them are gone. That is why the axis breakdown lists counts as well as a score.

A fix that is not in force yet does not move the number. Where the file Hostveil writes is not the thing your host is running from — a Compose file the container has not been recreated from, a systemd drop-in the unit has not re-read, a sysctl drop-in that applies at the next boot — the fix is recorded as applied and the finding stays charged in the score. Those fixes say so, in the sentence under them and in fix --all’s summary, and they name the command that puts them in force. Run it and the next scan collects the improvement.

The number can still move before a daemon does. The rule above keys on the fix knowing that something has to re-read its file, and one case is deliberately left out: an sshd_config edit. The SSH checker reads that same file, so a re-check genuinely confirms the change — while sshd keeps serving from the configuration it loaded at startup, which may still permit what you just switched off. Restart it, which is what the fix tells you to do, and the two agree again.

Two hosts’ scores are not comparable, as above. Neither are two scans of the same host across a change in what is installed: install Trivy and the Vulnerabilities axis joins the average, which will move the overall without anything about your configuration having changed.

It says nothing about what your services do. No score derived from configuration can. An application with an authentication bypass on a perfectly hardened host scores 100, and the score is right about the host and useless about your situation.

It says nothing about what Hostveil does not check. Backups, secrets in your git history, the certificate your reverse proxy serves, whether anyone is reading the logs. The score is silent about all of it, and silence is not a pass.