Skip to content

Commit 2c14c88

Browse files
committed
Add API category token matching and new test cases for API5
- Introduced a new helper function `_has_api_category_token` in `validate.py` to improve matching of OWASP API category tokens. - Updated the validation logic to utilize the new function for better accuracy in detecting API-related vulnerabilities. - Added test cases in `test_ai_modules.py` to verify the correct triggering of deterministic findings for API5 and ensure that non-OWASP markers do not trigger false positives.
1 parent f496836 commit 2c14c88

2 files changed

Lines changed: 78 additions & 4 deletions

File tree

src/secnodeapi/ai/validate.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ def _class_keywords(result: TestResult) -> str:
3636
)
3737

3838

39+
def _has_api_category_token(category_text: str, api_number: int) -> bool:
40+
"""Match exact OWASP API category tokens like API1 without matching API10."""
41+
return re.search(rf"\bapi{api_number}\b", category_text) is not None
42+
43+
3944
def _deterministic_validate_result(result: TestResult) -> Optional[Finding]:
4045
"""Deterministic validators for high-value classes to reduce hallucinations."""
4146
category_text = _class_keywords(result)
@@ -46,16 +51,16 @@ def _deterministic_validate_result(result: TestResult) -> Optional[Finding]:
4651

4752
bola_like = (
4853
any(keyword in category_text for keyword in ("bola", "idor", "bfla", "broken object", "broken function"))
49-
or re.search(r"\bapi1\b", category_text) is not None
50-
or re.search(r"\bapi5\b", category_text) is not None
54+
or _has_api_category_token(category_text, 1)
55+
or _has_api_category_token(category_text, 5)
5156
)
5257
mass_assignment_like = (
5358
any(keyword in category_text for keyword in ("mass assignment", "bopla"))
54-
or re.search(r"\bapi3\b", category_text) is not None
59+
or _has_api_category_token(category_text, 3)
5560
)
5661
rate_limit_like = (
5762
any(keyword in category_text for keyword in ("rate limit", "ratelimit"))
58-
or re.search(r"\bapi4\b", category_text) is not None
63+
or _has_api_category_token(category_text, 4)
5964
)
6065

6166
if bola_like and is_2xx:

tests/test_ai_modules.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,3 +190,72 @@ async def fake_call_llm(*args, **kwargs):
190190
assert called is True
191191
assert confirmed == []
192192
assert suspected == []
193+
194+
195+
@pytest.mark.asyncio
196+
async def test_classify_findings_api5_token_triggers_deterministic(monkeypatch) -> None:
197+
test_case = TestCase(
198+
id="T-API5-1",
199+
name="Authorization check",
200+
description="token-only category marker",
201+
owasp_category="API5:2023",
202+
endpoint="/admin/actions",
203+
method="POST",
204+
)
205+
result = TestResult(
206+
test_case=test_case,
207+
status_code=200,
208+
response_body='{"ok":true}',
209+
response_headers={},
210+
request_url="https://api.example.com/admin/actions",
211+
request_headers={},
212+
request_body=None,
213+
response_time_ms=12.0,
214+
)
215+
216+
async def fail_if_called(*args, **kwargs):
217+
raise AssertionError("LLM should not be called for deterministic finding")
218+
219+
monkeypatch.setattr("secnodeapi.ai.validate.call_llm", fail_if_called)
220+
confirmed, suspected = await classify_findings([result])
221+
assert len(confirmed) == 1
222+
assert confirmed[0].validation_source == "deterministic"
223+
assert suspected == []
224+
225+
226+
@pytest.mark.asyncio
227+
async def test_classify_findings_api50_does_not_trigger_api5_deterministic(monkeypatch) -> None:
228+
test_case = TestCase(
229+
id="T-API50-1",
230+
name="Non-OWASP marker",
231+
description="custom internal category only",
232+
owasp_category="API50: custom internal tag",
233+
endpoint="/custom/path",
234+
method="GET",
235+
)
236+
result = TestResult(
237+
test_case=test_case,
238+
status_code=200,
239+
response_body='{"status":"ok"}',
240+
response_headers={},
241+
request_url="https://api.example.com/custom/path",
242+
request_headers={},
243+
request_body=None,
244+
response_time_ms=10.0,
245+
)
246+
called = False
247+
248+
async def fake_call_llm(*args, **kwargs):
249+
nonlocal called
250+
called = True
251+
return (
252+
'{"analysis":"not vulnerable","is_vulnerable":false,"cvss_score":0.0,'
253+
'"cvss_vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:N",'
254+
'"description":"no issue","remediation":"none","confidence":0.9}'
255+
)
256+
257+
monkeypatch.setattr("secnodeapi.ai.validate.call_llm", fake_call_llm)
258+
confirmed, suspected = await classify_findings([result])
259+
assert called is True
260+
assert confirmed == []
261+
assert suspected == []

0 commit comments

Comments
 (0)