Evidence & AI triage

How each finding gets its verdict, what the AI is and isn't allowed to do, and how accuracy is measured.

The pipeline for one finding

  1. AST context and data flow. SecureSense parses your Python code, finds the enclosing function and the exact flagged statement, and follows the data into the dangerous call: where it comes from, what it passes through, and who calls the function.
  2. Five evidence checks (below) run on that context.
  3. A bounded loop re-runs any check that failed for a transient reason (e.g. the CVE database was unreachable), at most 3 rounds.
  4. Synthesis turns the evidence into a classification, a rationale, and a priority score.

The five evidence checks

CheckWhat it looks at
Import / usageWhich dangerous call is flagged, where it really comes from (pickle.loads from the imported pickle module), and whether user input reaches its dangerous argument, shown as a path such as request.args (line 12) → assigned to name → report() passes it to run_query(). Also: debug-mode settings (on unconditionally, or only when an environment variable enables it)
Mitigation / sanitisationProtection on the flagged data's path, matched to the weakness: int() / float() / uuid.UUID() for any injection, shlex.quote for commands, html.escape / markupsafe.escape for XSS, secure_filename / os.path.basename for paths; validation guards that stop execution; parameterised SQL; subprocess with an argument list and no shell=True; or only constants reaching the call
ReachabilityThe function's direct callers and callees, and whether anything in the project refers to it at all (dead code)
ExposureWhether the code is reachable from a Flask / FastAPI / DRF route or a Django urls.py entry — directly or up to 4 callers away — and whether that route has an auth decorator (login_required, jwt_required, …)
Cross-scanner / CVEWhether both scanners agree, and known vulnerabilities (via OSV.dev) in the pinned dependencies the file imports

How the data flow works

For every finding, SecureSense traces what reaches the dangerous argument of the flagged call: the query in cursor.execute(query, params) (not the parameters), the command in subprocess.run(cmd, shell=True), the path in open(path), the URL in requests.get(url).

Sources (user input):

Environment variables are tracked too, but as configuration, never as attacker input.

What the trace follows: assignments, f-strings, concatenation, % and .format(), containers, loops (tags.append(t)), comprehensions and both sides of if / else.

Sanitisers count only on the data's path, and only for the weakness they address. An int() applied to a different variable doesn't protect the query, and HTML escaping doesn't stop SQL injection. If the data is sanitised on one branch but not another, the unsanitised path still counts.

Validation guards protect the code after them when they stop execution (abort(), return, raise):

A check that only logs and carries on doesn't count, and neither does startswith("/"), since //evil.example passes it. Looking a value up in a constant table (SORT_COLUMNS.get(choice, "name")) gives the table's value, never the user's key.

Across functions: helpers are summarised, so as_int(v) returning int(v) is recognised as a sanitiser. When a parameter reaches the dangerous call, SecureSense follows the function's callers (up to 3 levels) to see what they pass in.

What it never dismisses:

The analysis is static and per finding: dynamic dispatch, global state and data stored then read back elsewhere (cache, files) aren't followed.

The four verdicts

VerdictWhen
ConfirmedUser input reaches the flagged operation, nothing mitigates it, it's reachable from a web route without auth, and both scanners agree
Likely vulnerabilityDangerous and user input reaches it, but one link isn't established (single scanner, behind a login, …)
Likely false positiveSanitisation or a validation guard neutralises the data on its way, only constants or deployment settings reach the call, debug mode is only enabled through an environment variable, or the code is never used
InconclusiveNot enough evidence either way — it still goes to a human

Who decides: rules, not the AI

The benchmark (below) showed that a small language model deciding on its own ignored clear mitigation evidence and even dismissed a real vulnerability. So in SecureSense:

Fact-checking the explanation

The rules keep the model away from the decision, but its explanation is still free text, and a language model can make things up. So every explanation is checked against the evidence before you see it:

CheckRejects, for example
Citationsciting a check that doesn't exist or didn't run ([firewall]), or citing none at all
Quoted codequoting code that isn't in the function it was shown, e.g. os.system(cmd)
Routes and IDsnaming a route (/admin/exec) or a CVE / GHSA / PYSEC id the evidence doesn't contain
Claims"the input is sanitised" when no sanitiser was found; "no user input reaches it" when the data flow was traced; "requires login" when the route has no auth decorator; "both scanners agree" when only one reported it; "not reachable from a web route" when one was found
Verdictan explanation that argues against its own verdict ("…so this is a false positive" on a Confirmed finding)

A sentence counts as a claim only when it states something outright, so hedged wording ("it should be validated", "if the input were escaped…") is never rejected.

When an explanation fails, the model gets one retry with the problems listed. If the retry fails too, SecureSense uses the rules' verdict and explanation, and the model's request to raise an Inconclusive finding is dropped, because an explanation that failed the check can't justify a higher verdict. The dashboard shows the outcome under each explanation (Fact-checked, corrected on the second try, or failed the fact-check). Hover over it to see what was wrong.

On the 26-case benchmark with Groq gpt-oss-20b, 4 of 26 first explanations failed the check (3 cited no evidence; 1 said no user input reached the code when the data flow had been traced), and all 4 passed on the retry. The rules' own explanations all pass, so the checker doesn't flag correct text.

Choosing a model

OptionSettingNotes
Groq (recommended)securesense.llm = cloud, provider groqFree tier; usually a second or two per finding (more when the free-tier rate limit kicks in). Default model openai/gpt-oss-20b
OpenRouterprovider openrouterFree models available, e.g. qwen/qwen3.8-27b:free
Any OpenAI-compatible APIprovider custom + securesense.cloud.baseUrle.g. AgentRouter
Local, offlinesecuresense.llm = hybrid, Ollama with qwen2.5-coder:7bFree and private; ~35 s per finding on a CPU
No modelsecuresense.llm = rulesInstant; deterministic explanations

For a cloud provider, run SecureSense: Set API Key — the key is stored in your OS keychain. Cloud mode sends the flagged function and its evidence to that provider (Privacy & data).

Priority score

priority = verdict weight × severity weight × agreement multiplier
VerdictWeightSeverityWeight
Confirmed3Critical3
Likely vulnerability2High2
Inconclusive1Medium1
Likely false positive0Low0.5

The agreement multiplier is 1.5 when both scanners reported the finding, otherwise 1. A confirmed, critical, two-scanner finding scores 3 × 3 × 1.5 = 13.5.

Benchmark

SecureSense ships a regression benchmark: 44 hand-labelled findings (21 real vulnerabilities, 23 false alarms) in small Flask, FastAPI and Django apps, including deliberately hard cases. A finding counts as kept unless it's called a likely false positive, so:

Set 2 (18 cases) was added with the data-flow engine. It tests what a per-function check gets wrong:

ModeCasesPrecisionRecallFP ratePasses bar
Rules, with data-flow engine441.001.000.00✔
Rules, before data-flow engine260.791.000.20✔
LLM alone (qwen2.5-coder:7b)260.480.910.73✘

The data-flow engine first scored 14 of the 18 new cases (it dismissed one real injection built in a loop). The fixes are described in the progress log.

The benchmark was written alongside the rules, so treat it as a regression suite rather than an independent accuracy measurement; a perfect score here doesn't mean SecureSense is never wrong on real code. The acceptance bar is provisional.

Something unclear or wrong? Edit these docs on GitHub.