mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 03:49:26 +00:00
feat(three-stage-scan): add split-sink fixtures the regex scan misses
This commit is contained in:
@@ -156,7 +156,7 @@ This plan ships the three-stage scan without rebuilding CWE policy or the regex
|
||||
- _Requirements: 6.1, 6.3, 6.5, 6.6_
|
||||
- _Depends: 3.2, 6.2_
|
||||
|
||||
- [ ] 6.5 Surface map, hints, and worth-fixing in the report and honor fail-on
|
||||
- [x] 6.5 Surface map, hints, and worth-fixing in the report and honor fail-on
|
||||
- Markdown, manifest, and SARIF remain; the report gains map and worth-fixing sections.
|
||||
- `--fail-on worth-fixing` fails only when a worth-fixing verdict exists.
|
||||
- Observable completion: a scan with one worth-fixing verdict fails under that flag and passes under `--fail-on never`.
|
||||
@@ -166,7 +166,7 @@ This plan ships the three-stage scan without rebuilding CWE policy or the regex
|
||||
## Phase 5 — Validation: Grounded corpora and CI
|
||||
|
||||
- [ ] 7. Replace cheat-sheet success for stages 2 and 3
|
||||
- [ ] 7.1 Add split-sink fixtures for Python, JavaScript, Java, and C/C++
|
||||
- [x] 7.1 Add split-sink fixtures for Python, JavaScript, Java, and C/C++
|
||||
- Each language family has at least one case where source and sink are on different lines or assigned before use.
|
||||
- Observable completion: stage-1 pattern scan misses at least one planted sink in each family, documented in the manifest expected set.
|
||||
- _Requirements: 8.1_
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
#include <stdio.h>
|
||||
#include <sqlite3.h>
|
||||
|
||||
void search(sqlite3 *db, char *term) {
|
||||
char query[512];
|
||||
snprintf(query, sizeof(query), "SELECT * FROM items WHERE title LIKE '%%%s%%'", term);
|
||||
sqlite3_exec(db, query, 0, 0, 0); /* CWE-89 split-sink: query built on prior line (regex blind spot) */
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package example;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.sql.Connection;
|
||||
|
||||
class SplitSinkServlet {
|
||||
void search(HttpServletRequest request, Connection connection) throws Exception {
|
||||
String term = request.getParameter("q");
|
||||
String sql = String.format("select * from items where title like '%%%s%%'", term);
|
||||
connection.createStatement().executeQuery(sql); // CWE-89 split-sink: sql built on prior line (regex blind spot)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Split-sink SQL injection: query is built, then executed on the next line.
|
||||
const express = require("express");
|
||||
const app = express();
|
||||
|
||||
app.get("/search", (req, res) => {
|
||||
const term = req.query.q;
|
||||
const query = "select * from items where title like '%" + term + "%'";
|
||||
db.query(query); // CWE-89 split-sink: query built on prior line (regex blind spot)
|
||||
res.send("ok");
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Split-sink SQL injection: query is built, then executed on the next line."""
|
||||
from flask import Flask, request
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
|
||||
@app.get("/search")
|
||||
def search(db):
|
||||
term = request.args["q"]
|
||||
query = "select * from items where title like '%" + term + "%'"
|
||||
return db.execute(query) # CWE-89 split-sink: query built on prior line (regex blind spot)
|
||||
@@ -0,0 +1,20 @@
|
||||
name = "split-sink-c"
|
||||
language = "c_cpp"
|
||||
frameworks = ["native"]
|
||||
setup = []
|
||||
modes = ["quick", "standard"]
|
||||
known_noise = []
|
||||
baselines = []
|
||||
|
||||
[source]
|
||||
type = "local"
|
||||
path = "../fixtures/split-sink-c"
|
||||
|
||||
[[expected]]
|
||||
cwe = "CWE-89"
|
||||
class = "sql injection"
|
||||
path = "src/vuln.c"
|
||||
line = 7
|
||||
function = "search"
|
||||
sink = "sqlite3_exec"
|
||||
evidence = "sqlite3_exec runs a SQL query string built from attacker input on a prior line"
|
||||
@@ -0,0 +1,20 @@
|
||||
name = "split-sink-java"
|
||||
language = "java_web"
|
||||
frameworks = ["servlet"]
|
||||
setup = ["mvn test"]
|
||||
modes = ["quick", "standard"]
|
||||
known_noise = []
|
||||
baselines = []
|
||||
|
||||
[source]
|
||||
type = "local"
|
||||
path = "../fixtures/split-sink-java"
|
||||
|
||||
[[expected]]
|
||||
cwe = "CWE-89"
|
||||
class = "sql injection"
|
||||
path = "src/main/java/example/SplitSinkServlet.java"
|
||||
line = 10
|
||||
function = "search"
|
||||
sink = "executeQuery"
|
||||
evidence = "executeQuery runs a SQL string built from request input on a prior line"
|
||||
@@ -0,0 +1,19 @@
|
||||
name = "split-sink-javascript"
|
||||
language = "javascript_node_web"
|
||||
frameworks = ["node", "express"]
|
||||
setup = ["npm install"]
|
||||
modes = ["quick", "standard"]
|
||||
known_noise = []
|
||||
baselines = []
|
||||
|
||||
[source]
|
||||
type = "local"
|
||||
path = "../fixtures/split-sink-javascript"
|
||||
|
||||
[[expected]]
|
||||
cwe = "CWE-89"
|
||||
class = "sql injection"
|
||||
path = "src/app.js"
|
||||
line = 8
|
||||
sink = "query"
|
||||
evidence = "query runs a SQL string built from request input on a prior line"
|
||||
@@ -0,0 +1,20 @@
|
||||
name = "split-sink-python"
|
||||
language = "python_web"
|
||||
frameworks = ["flask"]
|
||||
setup = ["pip install flask"]
|
||||
modes = ["quick", "standard"]
|
||||
known_noise = []
|
||||
baselines = []
|
||||
|
||||
[source]
|
||||
type = "local"
|
||||
path = "../fixtures/split-sink-python"
|
||||
|
||||
[[expected]]
|
||||
cwe = "CWE-89"
|
||||
class = "sql injection"
|
||||
path = "app.py"
|
||||
line = 11
|
||||
function = "search"
|
||||
sink = "execute"
|
||||
evidence = "execute runs a SQL query string built from request input on a prior line"
|
||||
@@ -65,6 +65,10 @@ def test_repository_benchmark_manifests_load_and_point_to_fixtures() -> None:
|
||||
"javascript-vulnerable.toml",
|
||||
"python-vulnerable.toml",
|
||||
"python-web-smoke.toml",
|
||||
"split-sink-c.toml",
|
||||
"split-sink-java.toml",
|
||||
"split-sink-javascript.toml",
|
||||
"split-sink-python.toml",
|
||||
}
|
||||
for manifest_path in manifest_paths:
|
||||
manifest = load_benchmark_manifest(manifest_path)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Split-sink fixtures: stage-1 pattern scan must miss planted sinks (requirement 8.1)."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from openultrasast.benchmark import (
|
||||
BenchmarkManifest,
|
||||
BenchmarkRun,
|
||||
ExpectedFinding,
|
||||
evaluate_benchmark,
|
||||
load_benchmark_manifest,
|
||||
resolve_benchmark_source,
|
||||
)
|
||||
from openultrasast.findings import StaticFinding, quick_scan_findings
|
||||
from openultrasast.gate import LANGUAGE_MANIFESTS
|
||||
from openultrasast.preprocess import preprocess_repository
|
||||
from openultrasast.rank import rank_targets
|
||||
|
||||
MANIFEST_DIR = Path("benchmarks/manifests")
|
||||
SPLIT_SINK_MANIFESTS = (
|
||||
"split-sink-c",
|
||||
"split-sink-java",
|
||||
"split-sink-javascript",
|
||||
"split-sink-python",
|
||||
)
|
||||
|
||||
|
||||
def _quick_scan(target: Path) -> list[StaticFinding]:
|
||||
_, targets = preprocess_repository(target)
|
||||
return quick_scan_findings(target, targets, rank_targets(targets))
|
||||
|
||||
|
||||
def _planted_split_sinks(manifest: BenchmarkManifest) -> list[ExpectedFinding]:
|
||||
return [item for item in manifest.expected if item.rule_id is None]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", SPLIT_SINK_MANIFESTS)
|
||||
def test_stage1_misses_planted_split_sink(name: str) -> None:
|
||||
manifest_path = MANIFEST_DIR / f"{name}.toml"
|
||||
manifest = load_benchmark_manifest(manifest_path)
|
||||
target = resolve_benchmark_source(manifest_path, manifest)
|
||||
planted = _planted_split_sinks(manifest)
|
||||
assert planted, f"{name} must document a stage-1 miss without rule_id"
|
||||
|
||||
findings = _quick_scan(target)
|
||||
run = BenchmarkRun(benchmark_run_id="split-sink", root=Path("/tmp/split-sink"), manifest=manifest)
|
||||
result = evaluate_benchmark(run=run, mode="quick", findings=findings, scan_id=None, scan_run_dir=None)
|
||||
missed = {(miss.path, miss.cwe, miss.rule_id) for miss in result.misses}
|
||||
|
||||
for expected in planted:
|
||||
assert expected.line is not None
|
||||
assert expected.sink
|
||||
source = (target / expected.path).read_text()
|
||||
lines = source.splitlines()
|
||||
sink_line = lines[expected.line - 1]
|
||||
assert expected.sink.lower() in sink_line.lower(), f"{name} planted sink {expected.sink!r} missing on line {expected.line}"
|
||||
prior = "\n".join(lines[: expected.line - 1]).lower()
|
||||
assert any(token in prior for token in ("query", "sql")), f"{name} must assign the payload before the sink line"
|
||||
on_line = [finding for finding in findings if finding.path == expected.path and finding.line == expected.line]
|
||||
assert on_line == [], f"{name} stage-1 hit the planted split-sink on {expected.path}:{expected.line}: {on_line}"
|
||||
assert (expected.path, expected.cwe, None) in missed, (
|
||||
f"{name} evaluate_benchmark must record a miss for {expected.path}:{expected.line}"
|
||||
)
|
||||
|
||||
|
||||
def test_split_sink_fixtures_are_outside_stage1_gate_corpus() -> None:
|
||||
listed = {name for names in LANGUAGE_MANIFESTS.values() for name in names}
|
||||
leaked = [name for name in SPLIT_SINK_MANIFESTS if name in listed]
|
||||
assert leaked == [], "split-sink fixtures must not join LANGUAGE_MANIFESTS (stage-1 90% smoke gate)"
|
||||
Reference in New Issue
Block a user