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
- 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.
- Five evidence checks (below) run on that context.
- A bounded loop re-runs any check that failed for a transient reason (e.g. the CVE database was unreachable), at most 3 rounds.
- Synthesis turns the evidence into a classification, a rationale, and a priority score.
The five evidence checks
| Check | What it looks at |
|---|---|
| Import / usage | Which 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 / sanitisation | Protection 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 |
| Reachability | The function's direct callers and callees, and whether anything in the project refers to it at all (dead code) |
| Exposure | Whether 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 / CVE | Whether 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):
- Flask, Bottle and Quart
request.*; - Django
request.GET/POST/body/FILES/ … (alsoself.requestin class-based views); - FastAPI / Starlette
Requestobjects; - route-handler parameters (URL path and query values), except dependencies injected with
Depends(...); input()andsys.argv.
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):
if col not in ALLOWED: abort(400)if not re.fullmatch(...)if not value.isdigit()assert value in (...)if not path.startswith(BASE_DIR)- an HMAC signature check (
hmac.compare_digest) on the request body.
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:
- Data read back from a database (
fetchone()and similar) or fetched over HTTP stays undecided, because it could be second-order injection. - A finding CodeQL reported is never dismissed just because SecureSense found no source, since CodeQL traced one SecureSense may not know.
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
| Verdict | When |
|---|---|
| Confirmed | User input reaches the flagged operation, nothing mitigates it, it's reachable from a web route without auth, and both scanners agree |
| Likely vulnerability | Dangerous and user input reaches it, but one link isn't established (single scanner, behind a login, …) |
| Likely false positive | Sanitisation 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 |
| Inconclusive | Not 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:
- Deterministic rules make the keep / dismiss decision from the evidence above.
- The language model only writes the explanation — in plain English, citing the checks it relies on.
- For findings the rules leave Inconclusive, the model may raise the verdict to Likely or Confirmed. It can never dismiss a finding.
- Its answer must fit a strict JSON schema restricted to the verdicts it's allowed to give; anything else is rejected and the rules' own explanation is used.
- Any model error, timeout or rate limit falls back to the rules — the verdict's
modelfield always shows what produced it (rules-v1,hybrid:groq:…).
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:
| Check | Rejects, for example |
|---|---|
| Citations | citing a check that doesn't exist or didn't run ([firewall]), or citing none at all |
| Quoted code | quoting code that isn't in the function it was shown, e.g. os.system(cmd) |
| Routes and IDs | naming 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 |
| Verdict | an 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
| Option | Setting | Notes |
|---|---|---|
| Groq (recommended) | securesense.llm = cloud, provider groq | Free tier; usually a second or two per finding (more when the free-tier rate limit kicks in). Default model openai/gpt-oss-20b |
| OpenRouter | provider openrouter | Free models available, e.g. qwen/qwen3.8-27b:free |
| Any OpenAI-compatible API | provider custom + securesense.cloud.baseUrl | e.g. AgentRouter |
| Local, offline | securesense.llm = hybrid, Ollama with qwen2.5-coder:7b | Free and private; ~35 s per finding on a CPU |
| No model | securesense.llm = rules | Instant; 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
| Verdict | Weight | Severity | Weight |
|---|---|---|---|
| Confirmed | 3 | Critical | 3 |
| Likely vulnerability | 2 | High | 2 |
| Inconclusive | 1 | Medium | 1 |
| Likely false positive | 0 | Low | 0.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:
- Recall = real vulnerabilities kept (never dismissed),
- Precision = kept findings that are real,
- False-positive rate = false alarms still left for a human.
Set 2 (18 cases) was added with the data-flow engine. It tests what a per-function check gets wrong:
- a sanitiser applied to a different variable;
- a guard that only logs;
- sanitisation on one branch only;
- the wrong sanitiser for the weakness;
- input arriving two calls away;
- FastAPI parameters and Django class-based views;
- a weak
startswith("/")redirect check.
| Mode | Cases | Precision | Recall | FP rate | Passes bar |
|---|---|---|---|---|---|
| Rules, with data-flow engine | 44 | 1.00 | 1.00 | 0.00 | ✔ |
| Rules, before data-flow engine | 26 | 0.79 | 1.00 | 0.20 | ✔ |
LLM alone (qwen2.5-coder:7b) | 26 | 0.48 | 0.91 | 0.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.