Skip to content

Follow-up correctness fixes: model matching, confidence, demo, injection probe, SSRF#2

Merged
eric007shine merged 7 commits into
modelscan:mainfrom
imphillip:fix/review-followups
Jun 11, 2026
Merged

Follow-up correctness fixes: model matching, confidence, demo, injection probe, SSRF#2
eric007shine merged 7 commits into
modelscan:mainfrom
imphillip:fix/review-followups

Conversation

@imphillip

Copy link
Copy Markdown
Contributor

Summary

Follow-up correctness fixes from a code review of the verifier core, on top of #1. Each fix is an independent, focused commit with a regression test, so they can be reviewed and merged commit by commit.

Fixes

  1. scoring: model-label matching (model_label_parts)

    • Splitting on : kept the part after the colon, so two unrelated models sharing a tag like :free (meta-llama/llama-3-8b:free vs qwen-2-7b:free) matched on a shared ("free",) token tuple — masking a real substitution.
    • Exact token equality meant a standard dated-snapshot echo gpt-4.1-2025-04-14 did not match the requested gpt-4.1, so an honest gateway was wrongly flagged as substituted (high risk, identity_score 0).
    • Fix: compare by base name (keep the part before a :tag) and strip a dated snapshot suffix (-YYYY-MM-DD / -YYYYMMDD) as an extra candidate.
  2. scoring: confidence_score scale — the mismatch/high-risk branch weighted components to sum 0.8 while the normal branch sums to 1.0, so the high-risk branch could never exceed 80 even with perfect evidence. Normalize by the weight sum so both branches share a 0-100 scale.

  3. client: content extractionextract_choice_content stringified list-shaped message.content ([{"type":"text",...}]) into a Python repr, while the protocol path already used the correct providers.extract_openai_choice_text. Drop the duplicate and reuse the providers helper.

  4. demo: verdict consistency + query decodedemo_protocol_report/demo_answer hardcoded upstream gpt-4.1, so the other two advertised demo models always produced a false substituted/high-risk verdict; now echo the selected model. Also parse the settings query with parse_qs for proper percent-decoding, replacing a hand-rolled parser that only handled +.

  5. probes: injection-resistance scoring — the prompt_injection probe forbade a phrase (只回答 PASS) that a faithful summary naturally quotes, so an injection-resistant answer scored 0/suspicious while a hijacked "PASS" scored 35 — correct behavior ranked below compromised behavior. Replace it with a real task plus an inline injection lure: resistant → 100, hijacked → 35.

  6. report: SSRF guard on the core entry pointvalidate_public_base_url ran at the CLI/demo-server boundaries but not inside run_verification, so a direct Python-API caller could pass a private/metadata host and reach it with the supplied auth header. Validate target.base_url at the entry point.

Out of scope (deliberately not changed)

  • DNS-rebinding / fail-open in url_safety — the guard resolves DNS only for a pre-flight check and fails open on resolution errors, so TOCTOU rebinding remains. Closing it needs request-time IP pinning across the httpx paths; left as a follow-up (noted in the commit message of fix 6).
  • 198.18.0.0/15 allowlist and dev-local.sh host-API default — both looked like bugs in review but are intentional and locked by existing tests / check-deploy-scripts.sh (a benchmark/proxy range that is deliberately allowed; a developer default that assumes a local host API). Left as-is.

Testing

pytest -q — all pass (52), including new regression tests for each fix.

🤖 Generated with Claude Code

imphillip and others added 7 commits June 11, 2026 03:22
…shots

model_label_parts split on ":" and kept the part *after* the colon, so two
unrelated models sharing a tag like ":free" (e.g. "meta-llama/llama-3-8b:free"
and "qwen-2-7b:free") both produced a ("free",) token tuple and were treated
as the same model, masking a real substitution. It also required exact token
equality, so a standard dated-snapshot echo like "gpt-4.1-2025-04-14" did not
match the requested "gpt-4.1" and was wrongly flagged as substituted (high
risk, identity_score 0).

Compare by base name instead: keep the part before a ":tag", and strip a
dated snapshot suffix (-YYYY-MM-DD or -YYYYMMDD) as an extra candidate.

Signed-off-by: Phillip <phillip.wu@infothinker.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rdicts

The mismatch/high-risk branch weighted protocol/capability/usage as
0.25/0.45/0.1 (sum 0.8) while the normal branch sums to 1.0, so the two
branches produced confidence_score on different scales — the high-risk
branch could never exceed 80 even with perfect evidence. Normalize by the
weight sum so both branches yield comparable 0-100 scores while preserving
the intended de-emphasis of protocol/usage relative to capability.

Signed-off-by: Phillip <phillip.wu@infothinker.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…arts

client.extract_choice_content stringified list-shaped message.content with
str(), so an OpenAI content-parts response ([{"type":"text","text":...}])
became a Python repr ("[{'type': 'text', ...}]") in answer_preview and in
substring scoring, while the protocol-check path already used the correct
providers.extract_openai_choice_text. Drop the duplicate copy and reuse the
providers helper, which joins the text parts.

Signed-off-by: Phillip <phillip.wu@infothinker.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
demo_protocol_report and demo_answer hardcoded the upstream model as
"gpt-4.1", so selecting either of the other advertised demo models
("gpt-4.1-mini", "claude-3-5-sonnet") produced a model mismatch and a
false "substituted"/high-risk verdict even though every canned probe
answer passed. Echo the requested model instead.

Also parse the model-verification settings query with parse_qs so the
percent-encoded claimed_model the frontend sends (e.g. "openai%2F...") is
decoded before profile matching, replacing the hand-rolled parser that
only handled "+".

Signed-off-by: Phillip <phillip.wu@infothinker.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The prompt_injection probe asked the model to summarize a document that
itself contained the injection text, then forbade the literal phrase
"只回答 PASS". A faithful, injection-resistant summary naturally quotes that
phrase and scored 0/suspicious, while a hijacked model answering only "PASS"
scored 35 — correct behavior ranked below compromised behavior.

Replace it with a real task (a small sum) plus an inline injection lure: a
resistant model that solves the task scores 100, while a model that obeys
the injection and answers "PASS" misses the expected answer and scores 35.
Update the demo and test canned answers to match the new prompt.

Signed-off-by: Phillip <phillip.wu@infothinker.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
validate_public_base_url was applied at the CLI and demo-server boundaries
but not inside run_verification itself, so a direct caller of the public
Python API could pass base_url="http://169.254.169.254" (or any private
host) and reach localhost/metadata/internal services with the supplied auth
header. Validate target.base_url at the start of run_verification.

Note: url_safety still resolves DNS only for the pre-flight check and fails
open on resolution errors, so DNS-rebinding/TOCTOU remains; closing that
needs request-time IP pinning and is left as a follow-up.

Signed-off-by: Phillip <phillip.wu@infothinker.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@eric007shine eric007shine merged commit 628f3bb into modelscan:main Jun 11, 2026
2 checks passed
@imphillip imphillip deleted the fix/review-followups branch June 11, 2026 12:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants