Severity should depend on who can reach the code
Here are two SQL injections in the same Express application. One is on a public search endpoint that anyone on the internet can call. The other is on a reporting screen that only administrators can open, behind single sign-on. The vulnerable line is almost identical in both.
Many scanners rate them the same, because severity is attached to the rule: "SQL injection" is Critical wherever it appears. The result is a list where everything at the top looks equally urgent, and the team has to redo the risk assessment by hand for every row.
They are not the same risk. Both should be fixed, but one can be exploited by anyone right now and the other needs an attacker who already holds an administrator account. Severity that cannot tell them apart is not telling you much.
Where per-pattern severity comes from
Rule-level severity is close in spirit to a CVSS base score. FIRST's CVSS v4.0 specification says the Base Score "reflects the severity of a vulnerability according to its intrinsic characteristics". Intrinsic means independent of where the flaw sits and who can reach it.
FIRST is explicit that this is not the whole picture. The CVSS v4.0 user guide has a section headed "CVSS Base Score (CVSS-B) Measures Severity, not Risk", and the specification says consumers "should enrich the Base metrics with Threat and Environmental metric values." The environmental part is exactly the question a code scanner is well placed to answer, because it has the code: which routes are public, which ones require a login, which ones require a role.
The OWASP Risk Rating Methodology makes the same point from another direction. It estimates risk as likelihood times impact, and one of its likelihood factors is the size of the group that could exploit the flaw. On its scale, developers and system administrators score 2, authenticated users 6 and anonymous internet users 9. Who can reach the code is not a detail. It is one of the main inputs.
The four questions
Since 0.30.0, Vulkro sets severity from one model instead of rule by rule. For each finding it asks four questions.
Who can reach the code? The answer comes from the entry point the finding sits behind, and the guards on it:
- Anonymous: the route carries no authentication.
- Any signed-up user: authentication applies, but the application has open sign-up, so anyone can create an account and get past it.
- An internal user: authentication applies and accounts are issued, not self-service.
- An administrator: a role or admin check applies.
The difference between the second and third is easy to miss and matters a lot. A login screen is not much of a barrier if the sign-up form is next to it. Vulkro infers open sign-up from the code (a registration route, a user-creation form). When sign-up happens somewhere the repository does not show, such as a hosted identity provider, you can declare it with open_signup = true or false in vulkro.toml.
MCP server tools and AI agent tools count as entry points too. Their arguments are steered by whatever text the model read, so they are treated as untrusted input rather than as an internal caller.
What is at stake? Code execution, file access, account takeover, other users' data, and internal network reach (server-side request forgery, which counts fully only when the response comes back to the attacker). A flaw that hands over one of these is worth more than a missing security header.
Is the path proven? A traced data flow from the request to the dangerous call is proof. So is a route whose guard facts show the missing check. A pattern match on its own is not.
Does a guard stand in the way? If a check the scanner can see answers the question the finding raises (an ownership check for an object lookup, an allowlist or a type restriction for an injection), the finding is not placed at the top.
How the answers become a severity
When all four line up (an attacker from the public, something serious at stake, a proven path, nothing in the way), the finding is Critical. When exactly one of them is missing, it is High. When two or more are missing, it is Medium.
That gives the two injections from the start of this post different answers:
// Reachable by anyone on the internet
app.get('/api/search', async (req, res) => {
const rows = await db.query(
`SELECT id, name FROM products WHERE name LIKE '%${req.query.q}%'`
);
res.json(rows);
});
// Reachable only by administrators
app.get('/admin/reports', requireAdmin, async (req, res) => {
const rows = await db.query(
`SELECT * FROM orders WHERE region = '${req.query.region}'`
);
res.json(rows);
});
The first has an anonymous caller, other users' data at stake, a traced flow from req.query.q into the query, and no guard. That is Critical. The second has the same flow and the same stakes, but when the guard on the route is recognised as an admin check, the caller is an administrator, and the finding is placed at High. It is still reported, and it still deserves a fix. It just does not compete with the public one for the first slot in the queue.
The fix is the same for both:
app.get('/api/search', async (req, res) => {
const rows = await db.query(
'SELECT id, name FROM products WHERE name LIKE $1',
[`%${req.query.q}%`]
);
res.json(rows);
});
Where the code lives matters too
The same model reads where a file comes from: first-party, test, example, generated, vendored or browser code. A command injection in a test fixture, an example folder or a vendored copy of a library is not the same as one in your request handlers. Findings in test, example and vendored code are reported at Low at most, and server-side rules no longer fire on browser code, where "the server runs this" was never true.
This removes a large and boring class of noise: the hard-coded credentials in test setup, the deliberately vulnerable snippet in a docs example, the third-party bundle nobody on the team edits.
Every placed finding says why
A severity that moves needs an explanation, or it looks arbitrary. When the model places a finding, the finding's reason says which answers placed it there: who can reach it, what is at stake, whether the path is proven and whether a guard applies. If you disagree, you can see which answer to argue with. A route the model thought was anonymous might be protected by a gateway it cannot see. A sign-up flow might be invite-only. The reason tells you where to look, and open_signup in vulkro.toml covers the most common correction.
When the model cannot answer (no entry point reaches the finding, or the route's authentication cannot be classified) it does not guess. The finding keeps the severity it had.
The same model for Salesforce
vulkro-sf applies the same idea to the Salesforce platform. A proven path from an unauthenticated guest user to sensitive data is Critical. The same flaw that only an administrator can reach, or that touches no sensitive data, is rated lower. A permission grant that no user actually holds is Low. We walked through a guest path step by step in the anatomy of a guest user data leak.
What this does not mean
Lower severity is not a statement that the code is safe. Administrator accounts get phished, internal users go bad, and a signed-up attacker is still an attacker. The model is for ordering work, not for deciding what to ignore. Everything it places lower is still in the report.
It also depends on what the scanner can see. A guard enforced by an API gateway, a service mesh or a framework configuration outside the repository is invisible to source analysis. That is why the reason is on every placed finding: so a wrong answer is easy to spot and correct.
Severity is one of three things we try to keep honest. The other two are what a scan actually read and whether a finding is proven, unproven or not checked.
Where to see it
Run vulkro scan on an application with a mix of public and authenticated routes and read the reason on the top findings. The attack paths docs show how entry points join to the code they reach, and the confidence model explains the separate confidence level each finding carries. For why the list beats a flat table, see attack paths, not findings lists. To get set up, start here.
