fix(ai): ground AI endpoints in scan evidence and guard prompts and output - #359
Merged
Merged
Conversation
…utput The AI endpoints trusted a client-supplied findings array, pasted finding text straight into prompts next to the instructions, and passed raw model text through whenever JSON parsing failed (OWASP LLM Top 10: LLM01, LLM05). Evidence - Findings are loaded server-side from a completed scan: scan_id when given, otherwise the latest completed scan (the /api/findings default). The client-supplied findings array still works but is deprecated and cannot be combined with scan_id. - Every response carries an evidence object (source, scan_id, verified, finding_count, findings_in_prompt). Required endpoints fail closed: 404 with no completed scan, 422 when it has no findings, 503 when the lookup fails. Prompts (api/services/ai_guard.py) - Finding fields and the question are stripped of control, bidi and zero-width characters, collapsed to one line, capped, JSON-encoded and placed in data blocks whose delimiters carry a per-request random boundary, with instructions to treat block content as evidence only. - The knowledge-base query is built from rule IDs and names only, so untrusted text no longer steers retrieval. Output - /prioritise and /threat-simulation validate the model's JSON against the evidence. Items citing rules or resources outside it are dropped and counted; malformed output returns 502 instead of raw text. - /prioritise with no findings returns an empty list without a model call. The dashboard no longer sends findings; it lets the server read the scan. Closes OWASP#357 Signed-off-by: parthrohit22 <parthrohit60@gmail.com>
parthrohit22
requested review from
SHAURYAKSHARMA24,
Vishnu2707,
ritiksah141 and
vogonPrayas
as code owners
September 25, 2026 23:12
- Bandit B613 (trojan source): the bidi/zero-width ranges in
_CONTROL_CHARS, the ellipsis, and the test payloads were written as
literal characters. They are now \uXXXX escapes, so every changed file
is pure ASCII and nothing invisible sits in the source.
- CodeQL py/polynomial-redos: the code-fence regex could backtrack
polynomially on hostile model output ("```" followed by many spaces).
Fence stripping is now plain string handling, with a regression test
that a 200k-character hostile fence is rejected in under a second.
Signed-off-by: parthrohit22 <parthrohit60@gmail.com>
TFT444
approved these changes
Sep 26, 2026
TFT444
left a comment
Collaborator
There was a problem hiding this comment.
Approving. Solid security hardening closing #357. Evidence loaded server-side, fenced prompt boundaries with random hex tokens prevent injection escaping, output validated against scan evidence. 1413 tests pass, all CI green. Three minor non-blocking notes for follow-up: broad except Exception on line 174 of ai_guard.py, no graceful fallback if DATABASE_URL is missing (KeyError vs 503), and one empty GitHub Advanced Security review comment worth checking.
ritiksah141
approved these changes
Sep 26, 2026
ritiksah141
left a comment
Collaborator
There was a problem hiding this comment.
these are
Nits (non-blocking, none worth holding the PR)
- Threat-simulation can return 200 with stages: [] if every stage cited only invented rules; discarded_items is the only hint. A deliberate choice, better than a 502.
- finding_record doesn't normalize severity going into the prompt (prompt-only echo of DB values; the validator normalizes on output). Benign.
- The injection corpus exercises summary/ask/insights prompts; prioritise/threat-sim share the same _findings_block builder so coverage is effectively shared.
But approving it as safe to merge.
parthrohit22
added a commit
to parthrohit22/openshield
that referenced
this pull request
Sep 27, 2026
Picks up OWASP#344 (branch rulesets, post-merge CI) and OWASP#359 (evidence-grounded AI endpoints). Only CHANGELOG.md conflicted; kept both sides' entries. OWASP#359's AI routes use get_scan/get_latest_completed_scan/get_findings, whose signatures this branch leaves unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JMtsuR7tvJTsoq5KueraLf Signed-off-by: parthrohit22 <parthrohit60@gmail.com>
This was referenced Sep 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
Grounds the AI endpoints in persisted scan evidence, fences untrusted finding text against prompt injection, and validates model JSON against that evidence instead of returning raw model text (OWASP Top 10 for LLM Applications: LLM01, LLM05).
Type of change
What changed
Evidence comes from the database, not the request body
/api/ai/{summary,insights,prioritise,ask,threat-simulation}load findings server-side from a completed scan:scan_idwhen given, otherwise the latest completed scan (the same default asGET /api/findings).findingsarray still works for compatibility, but it is deprecated, labelledverified: false, and can't be combined withscan_id.evidenceobject:source,scan_id,verified,finding_count,findings_in_prompt.404with no completed scan,422when the scan has no findings,503when the lookup fails. At most the 200 most severe findings go into one prompt.Untrusted text is fenced (
api/services/ai_guard.py)Model output is validated
/prioritiseand/threat-simulationparse the model's JSON (tolerating a Markdown code fence) and check it against the evidence.discarded_items. Stages outside the documented set are dropped.502 {"error": "AI response failed validation"}. Raw model text is never passed through./prioritisewith no findings returns an empty list without calling the model.Dashboard
aiApino longer sends findings and accepts an optionalscanIdinstead. This also fixes a silent bug: the dashboard was sending its camelCase view (resourceName,ruleName), which the backend never read, so most fields reached the model as "Unknown".Docs: new "AI endpoints" section in
docs/api-reference.md(these endpoints were undocumented), plus a CHANGELOG entry under Security.Testing
tests/test_ai_prompt_guard.pycovers:/prioritiseand/threat-simulationaiApi.test.mjshas two new checks: requests never includefindings, andscanIdis forwarded asscan_id.ruff check/ruff format --checkare clean. Frontend lint, build and the node tests pass.resource_namecarried an injected instruction andAZ-FAKE-999. The invented rule was dropped from/prioritise, the real finding was kept, the injected newline was flattened, nothing reached the instruction section, and an unknownscan_idreturned 404.Not tested against a live LLM provider; provider calls are mocked in tests as elsewhere in the suite.
Related issue
Closes #357
Related: #313 has prompt-injection acceptance criteria for the remediation agent. The helpers in
ai_guard.pyare meant to be reused there rather than built twice.Checklist
Signed-off-bytrailer (git commit -s; seedocs/dco.md)