Skip to main content

Confidence model

Every finding Vulkro emits carries a confidence: High | Medium | Low value plus a confidence_reason: String that explains why the engine fired and what would mark this a false positive. This is the heart of how we keep a high-recall scanner usable.

Levels

LevelMeaningWhen you'd see it
HighStrong, often runtime-confirmed evidence.Provider-format secrets, taint-confirmed injection paths, KEV-listed CVEs, vulkro probe confirmations.
MediumPattern matches but with credible false-positive shape.Heuristic IDOR (path takes an :id and no auth check is visible, but might be wrapped). Generic secret patterns without provider format.
LowHeuristic. Often fires on test/example/migration code.Pattern-match-only checks where context confirms little.

vulkro scan defaults to --min-confidence high. To see the rest:

vulkro scan . --min-confidence medium
vulkro scan . --all

Strict-confidence filter

For CI gates that want maximal precision, vulkro scan --strict-confidence (or VULKRO_STRICT_CONFIDENCE=1) layers an additional filter on top of the --min-confidence cut: any High finding whose evidence bag is empty is dropped at output time. A finding survives the strict cut only when it carries at least one populated Evidence item, which means at least one corroborating signal (a taint-flow source-to-sink, an exact-match invariant such as a SUP-COMPROMISE catalog row, a schema-vs-code contradiction, or a pair of pattern signals whose weights sum past the cumulative threshold).

Off by default; behaviour-preserving for callers who don't opt in. See the strict-confidence section on vulkro scan for the flag's surface and the env-var equivalent.

Per-detector-category tiers

The generic High | Medium | Low table above tells you the shape of a finding's confidence. What each level specifically means is detector-specific. The table below is the authoritative per-category reading; every rule page (under /docs/rules and siblings) links back here.

Detector categoryHigh meansMedium meansLow means
Secrets (secrets, git_history)Provider-format match (AWS AKIA, Stripe sk_live, GitHub ghp_, etc.) on a non-test path. The string is shaped like a real credential.Generic high-entropy literal that matches no provider format, but assigned to a *_KEY | _TOKEN | _PASSWORD identifier. Could be a placeholder.Long hex / base64 string with no naming signal. Frequently a hash, content checksum, or test fixture.
Auth (auth_dataflow, authz)Route is reachable AND no auth middleware, dependency, decorator, or guard is visible in the import scope or call graph.Route looks unprotected at the file level, but a global mount (app.use('/api', ...)) or framework convention (APP_GUARD) could be applying auth at runtime.Route name pattern-matches admin/internal/private but no behavioural signal supports the claim.
IDOR / BOLA (idor)Handler reads a path/query param like :id and resolves the object without a where(user=...), .filter(owner=...), or equivalent scoping clause; and taint flows from the param into the lookup.Handler takes an :id and the lookup is unscoped, but no taint analysis confirmation (the param might be derived).Heuristic only: an :id route exists with no visible check, used for forensic mode.
Mass assignment (mass_assignment)Body-to-model bind via Model(**req.json), Object.assign(user, req.body), setattr loop over body, etc., on a model whose schema has fields the API surface does not allowlist.Body-to-model bind on a model whose schema we cannot fully resolve (cross-file fragment, dynamic ORM).Pattern match alone, no model resolution.
Injection (injection, injection_extra, taint)End-to-end taint chain from request source to a SQL / shell / template / eval sink without intervening sanitiser.Sink call with a likely-tainted argument but no full chain confirmation.Sink call with no taint corroboration; pattern-only.
Crypto (crypto_weakness, crypto_new)Exact-match invariant: md5(buf) for a password, MODE_ECB for confidentiality, verify=False on TLS. The construct is provably wrong regardless of context.Weak primitive in use, but might be for a non-security purpose (e.g. md5 for cache key).Reference to a weak primitive in a comment, docstring, or test name.
SCA / CVE (package_risk, slopsquat_known)Direct dependency matches a KEV-listed advisory at the exact pinned version.Transitive dependency matches an advisory; or the version match is a range hit not an exact one.Heuristic typosquat / new-package signal without a CVE backing it.
SSRF (security::ssrf, taint)Tainted URL flows into an HTTP client call without an allowlist check or hostname normalization between source and sink.Tainted URL flows into a sink but the chain crosses a function we couldn't fully resolve.Pattern only: URL-shaped argument to an HTTP client.
LLM (llm_security)Tainted user input flows into a model SDK call (openai.chat, anthropic.messages, langchain) without isolation or structured-tool constraint. Or: an env var known to hold a secret flows into the prompt.Tainted input near an LLM call but no full chain.LLM call with any string-template usage that could in principle contain user data.
CSRF / cookies (csrf, cookies)Session-cookie middleware is mounted AND CSRF middleware is absent or commented out; or cookie is set with SameSite: 'none' without Secure.Session middleware present, CSRF middleware unclear; or cookie missing one of Secure/HttpOnly/SameSite but not all.Pattern-only: a session-like cookie name with no other signal.

If a finding's tier surprises you, look up the category here first; the per-category meaning above is what the rule actually asserts. The matching rule page at /docs/rules walks through the OWASP category and its detectors in detail.

confidence_reason

Every finding has a one-line explanation:

confidence_reason = "taint flowed from req.body to db.query without sanitiser"
confidence_reason = "auth helper not found in import scope"
confidence_reason = "runtime-confirmed via active probe"
confidence_reason = "AKIA-prefix matches AWS access-key format"

Surfaced in JSON, SARIF (properties.confidence_reason), and the desktop console. Designed to be readable without re-reading source.

Calibration table

Vulkro holds a static calibration table that downgrades High -> Medium for detector categories scoring under 30% TP-rate on the 13-repo benchmark. The intent is that if a category is 70%+ false-positive in the wild, callers shouldn't be told "High confidence" by default.

The table is updated when benchmark numbers change. This keeps the default-mode signal-to-noise ratio honest as new detectors land.

Where the bar is

The published benchmark on 13 deliberately-vulnerable repos:

vulkro defaultvulkro --min-confidence highopen-source rules baselinemulti-language OSS scanner
precision0.210.620.600.45
recall0.910.760.220.45
F10.340.680.320.45

--min-confidence high is now the production-recommended cut: it leads the open-source rules baseline on precision (0.62 vs 0.60) and the broad multi-language scanner on recall (0.76 vs 0.45) on the same corpus. F1 0.68 beats both outright.

The lift came from three changes shipped on top of the Phase 4 AST- confirmation engine: the cumulative-weight High threshold was calibrated from 1.5 to 1.1 to match what shipped detectors actually emit (Phase 4 pairs at 0.7 + 0.5 = 1.2); four taint / template / autoescape emit sites were retrofitted to attach Evidence so the aggregator can survive the OWASP-category calibration downgrade; and a (file, line, message) dedup pass removes duplicate emissions from overlapping intra-/inter-procedural taint engines.

See Benchmark for the full methodology and per-repo breakdown.

Offline false-positive triage (--triage)

vulkro scan --triage adds an optional, default-off, fully-local pass that re-ranks findings by demoting the usual noise sources one confidence tier:

  • test code (test/, tests/, __tests__/, *.spec.*, *_test.go, ...)
  • fixtures / examples / samples / mocks
  • generated / vendored / minified output (dist/, vendor/, node_modules/, *.min.js, ...)
  • database migrations

It is deterministic (same input, same output) and makes no network call and uses no model - VULKRO_OFFLINE=1 is honoured. It is a path-based heuristic re-ranker, described as exactly that: it is not an ML classifier, and we do not call it "AI". (An optional on-device model layer is a future addition.)

Triage only ever lowers confidence and never drops a finding, so recall is preserved by construction: a demoted finding still appears under --all-confidence. The point is to combine it with --min-confidence high so the test/fixture/generated noise drops out of the default view while staying one flag away:

vulkro scan . --triage --min-confidence high