Skip to content

Commit 4485936

Browse files
GSoC 2026 Module B — Week 2: Stage 1 regex filter + Stage 1.5 sanitize (OWASP#928)
* feat(module-b): add Pydantic v2 schemas + hashing for Module A input contract Establishes the data contract Module B consumes from Module A. ChangeRecord is a Pydantic v2 model matching A's actual emission shape: nested source (discriminated union on type for github/rss), span (chunk position + heading_path + char/line offsets), and locator (addressing scheme). Internal models ClassifyResult and QueuePayload prep for later stages. hashing.py provides normalize_text + compute_content_hash since Module A does not emit content_hash; B computes its own (SHA-256 of normalized text) for use as the knowledge_queue dedup key. 22 unittest cases cover the round-trip, the discriminated union, hash determinism, normalization rules, code-fence preservation, and idempotency. Full make test: 271 passing, no regressions. Part of GSoC 2026 OpenCRE Scraper & Indexer (Project OIE) Module B. * feat(module-b): add Module A mock fixture + generated JSON Schema artifact module_a_mock.jsonl: Module A's canonical 20-record mock shared 2026-05-29, saved as JSONL (one record per line per the contract). Becomes a permanent integration-test fixture for B's parser and a reference shape for the Module A contributor. module_a_contract.schema.json: JSON Schema generated from B's Pydantic ChangeRecord model via model_json_schema(). 246 lines covering all four nested types (ChangeRecord, GithubSource, RssSource, Span, Locator). Source of truth for cross-module CI validation. Part of GSoC 2026 OpenCRE Scraper & Indexer (Project OIE) Module B. * feat(module-b): add OWASP harvester, labeling TUI, and labeled dataset build_labeled_dataset.py: PyGithub-based harvester that acts as Module A's stand-in for producing benchmark data. Fetches recent commits from 4 OWASP repos (WSTG, ASVS, CheatSheetSeries, SAMM), applies the contract's normalization rules, splits into chunks at markdown heading boundaries with a fence-aware stack-based walker that tracks heading_path + char/line offsets, and emits records in Module A's actual nested shape. Pluggable via GITHUB_TOKEN env var. Reproducible: python scripts/build_labeled_dataset.py regenerates the candidate set. label_dataset.py: resumable interactive TUI for manual classification. Atomic-writes labeled_data.json after every keystroke; lookup by chunk_id for resume. Embeds the recall-first definition (agreed with maintainer 2026-06-01) so labelers see the rule front-of-mind: KNOWLEDGE for any chunk with security signal, NOISE only for pure organizational content. candidate_commits.json: 100 records, 25 per repo, all Pydantic-valid against ChangeRecord. 90/100 have non-empty heading_path; 10 multi-chunk artifacts captured. labeled_data.json: 100/100 labeled by hand under the recall-first rule. Distribution 55 KNOWLEDGE / 40 NOISE / 5 UNCERTAIN. Per-repo skew is visible: CheatSheetSeries 92% K, SAMM 0% K (the SAMM commits sampled landed entirely on Website/Sponsorship/meetings paths -- empirical input for Week 2's noise_patterns.yaml). Part of GSoC 2026 OpenCRE Scraper & Indexer (Project OIE) Module B. * style(module-b): apply Black formatting to Week 1 files Super-Linter (Black 24.4.2) flagged 4 files in the previous push. Applied `black` (same pinned version) to bring them in line with the repo's formatting standard. Cosmetic changes only: blank lines around section-separator comments, one multi-line dict join. No behavior or test changes -- `make test` remains 271 passing, 1 skip. * chore(module-b): address CodeRabbit Week 1 review comments - Sort __all__ lists in hashing.py and schemas.py to satisfy Ruff RUF022. - Declare JSON Schema dialect ($schema = draft 2020-12, which is what Pydantic v2 model_json_schema() emits) on the contract artifact. - Wrap load_labeled() in scripts/label_dataset.py with try/except so a corrupted labeled_data.json prints an actionable hint instead of a raw JSONDecodeError stack trace. Deferred to Week 2 (will be addressed when we touch the harvester): - chunker should also track <pre> open/close, not just ``` fences - _split_chunk_by_size cursor arithmetic assumes \\n\\n separator even on hard-split sub-chunks Tests: 271 passing, 1 skip (unchanged). Black: clean. * feat(module-b): add Stage 1.5 sanitize.py vendored from TRACT Defensive text cleanup (PDF ligatures, zero-width chars, HTML, hyphenation). Vendored from rocklambros/TRACT under CC0; drops their whitespace-collapse step so structure (newlines, paragraphs) is preserved for Module B's LLM. 26 unit tests, all passing. * feat(module-b): add Stage 1 regex_filter + noise_patterns.yaml Path-based filter with extension/filename/glob deny rules and allow_overrides. Patterns are deliberately conservative under the recall-first labeling rule. 15 unit tests including >=90% rejection / 0% false-positive acceptance criteria. * fix(module-b): chunker tracks <pre> blocks; correct hard-split cursor math Addresses CodeRabbit comments #4 and #5 on the Week 1 PR. * chore(module-b): address CodeRabbit Week 2 review comments * chore(module-b): address Week 2 maintainer review on noise_patterns.yaml --------- Signed-off-by: Manshu Saini <149303743+manshusainishab@users.noreply.github.com>
1 parent 1b1679b commit 4485936

8 files changed

Lines changed: 947 additions & 42 deletions

File tree

application/tests/noise_filter/fixtures/module_a_mock.jsonl

Lines changed: 19 additions & 19 deletions
Large diffs are not rendered by default.
Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
"""Tests for application.utils.noise_filter.regex_filter.
2+
3+
Uses unittest to match the project-wide discovery pattern.
4+
5+
Coverage groups:
6+
1. ConstructionTests -- YAML loading, defaults, custom path
7+
2. PathRuleTests -- each precedence rule fires correctly in isolation
8+
3. RealisticPathTests -- table-driven against representative OWASP paths,
9+
asserting >=90% rejection on known junk and 0%
10+
on known docs (plan's acceptance criteria).
11+
4. RecordTests -- is_noise_record + filter_records on ChangeRecords
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import textwrap
17+
import unittest
18+
from pathlib import Path
19+
from tempfile import NamedTemporaryFile
20+
21+
from application.utils.noise_filter.regex_filter import (
22+
DEFAULT_PATTERNS_PATH,
23+
RegexFilter,
24+
)
25+
from application.utils.noise_filter.schemas import ChangeRecord
26+
27+
# --- Helper: build a minimal ChangeRecord ---------------------------------
28+
29+
30+
def _record_with_path(path: str) -> ChangeRecord:
31+
"""Build a minimal valid ChangeRecord whose locator.path is `path`."""
32+
return ChangeRecord.model_validate(
33+
{
34+
"schema_version": "0.2.0",
35+
"chunk_id": f"chk:test:{path}",
36+
"artifact_id": f"art:test:{path}",
37+
"pipeline_run_id": "20260607T000000Z",
38+
"text": "non-empty text",
39+
"span": {"index": 0, "total": 1, "heading_path": []},
40+
"source": {
41+
"type": "github",
42+
"repo": "OWASP/test",
43+
"commit_sha": "abc123",
44+
"committed_at": "2026-06-07T00:00:00Z",
45+
},
46+
"locator": {"kind": "repo_path", "id": path, "path": path},
47+
}
48+
)
49+
50+
51+
# --- 1. Construction ------------------------------------------------------
52+
53+
54+
class ConstructionTests(unittest.TestCase):
55+
56+
def test_default_patterns_file_loads(self) -> None:
57+
filt = RegexFilter()
58+
# Should have non-empty rule sets from the shipped YAML.
59+
self.assertTrue(len(filt.deny_extensions) > 0)
60+
self.assertTrue(len(filt.deny_filenames) > 0)
61+
self.assertTrue(len(filt.deny_paths) > 0)
62+
self.assertEqual(filt.patterns_path, DEFAULT_PATTERNS_PATH)
63+
64+
def test_custom_patterns_path(self) -> None:
65+
custom_yaml = textwrap.dedent(
66+
"""\
67+
deny_extensions: [.foo]
68+
deny_filenames: [BAR]
69+
deny_paths: ["baz/**"]
70+
allow_overrides: []
71+
"""
72+
)
73+
with NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
74+
f.write(custom_yaml)
75+
tmp = Path(f.name)
76+
try:
77+
filt = RegexFilter(patterns_path=tmp)
78+
self.assertEqual(filt.deny_extensions, (".foo",))
79+
self.assertEqual(filt.deny_filenames, frozenset({"BAR"}))
80+
self.assertEqual(filt.deny_paths, ("baz/**",))
81+
self.assertEqual(filt.allow_overrides, ())
82+
finally:
83+
tmp.unlink()
84+
85+
def test_empty_yaml_handled(self) -> None:
86+
"""Empty YAML loads to None; the filter must tolerate this."""
87+
with NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
88+
f.write("")
89+
tmp = Path(f.name)
90+
try:
91+
filt = RegexFilter(patterns_path=tmp)
92+
self.assertEqual(filt.deny_extensions, ())
93+
self.assertEqual(filt.deny_filenames, frozenset())
94+
# Everything passes through.
95+
self.assertEqual(filt.is_noise_path("anything.css"), (False, ""))
96+
finally:
97+
tmp.unlink()
98+
99+
100+
# --- 2. Per-rule precedence ------------------------------------------------
101+
102+
103+
class PathRuleTests(unittest.TestCase):
104+
105+
@classmethod
106+
def setUpClass(cls) -> None:
107+
cls.filt = RegexFilter()
108+
109+
def test_extension_match(self) -> None:
110+
is_noise, reason = self.filt.is_noise_path("frontend/styles/app.css")
111+
self.assertTrue(is_noise)
112+
self.assertIn("deny_extension", reason)
113+
self.assertIn(".css", reason)
114+
115+
def test_filename_match(self) -> None:
116+
is_noise, reason = self.filt.is_noise_path("frontend/package-lock.json")
117+
self.assertTrue(is_noise)
118+
self.assertIn("deny_filename", reason)
119+
120+
def test_path_glob_match(self) -> None:
121+
is_noise, reason = self.filt.is_noise_path("tests/integration/foo.py")
122+
self.assertTrue(is_noise)
123+
self.assertIn("deny_path", reason)
124+
125+
def test_any_depth_test_path(self) -> None:
126+
"""**/tests/** should catch deeply-nested test directories."""
127+
is_noise, _ = self.filt.is_noise_path(
128+
"application/utils/something/tests/foo.py"
129+
)
130+
self.assertTrue(is_noise)
131+
132+
def test_passing_doc_path(self) -> None:
133+
"""A normal security doc path should be accepted."""
134+
is_noise, reason = self.filt.is_noise_path(
135+
"document/4-Web_Application_Security_Testing/04-Authentication_Testing/01-Testing.md"
136+
)
137+
self.assertFalse(is_noise)
138+
self.assertEqual(reason, "")
139+
140+
def test_allow_override_wins_over_deny(self) -> None:
141+
"""An allow-override beats any deny rule on the same path."""
142+
custom = textwrap.dedent(
143+
"""\
144+
deny_extensions: [.md]
145+
deny_filenames: []
146+
deny_paths: ["docs/**"]
147+
allow_overrides: ["docs/**"]
148+
"""
149+
)
150+
with NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
151+
f.write(custom)
152+
tmp = Path(f.name)
153+
try:
154+
filt = RegexFilter(patterns_path=tmp)
155+
# Without the override, this would be denied twice (extension + path).
156+
# With it, accepted at both top-level and nested.
157+
self.assertFalse(filt.is_noise_path("docs/guide.md")[0])
158+
self.assertFalse(filt.is_noise_path("docs/designs/spec.md")[0])
159+
finally:
160+
tmp.unlink()
161+
162+
163+
# --- 3. Realistic paths -- plan's acceptance criteria --------------------
164+
165+
166+
# Representative paths chosen from real OWASP repos (WSTG, ASVS, CheatSheets,
167+
# SAMM) plus common junk. Used to validate the >=90% / 0% rejection rates
168+
# specified in the original plan.
169+
170+
KNOWN_JUNK_PATHS = [
171+
# Lockfiles + build artifacts
172+
"frontend/package-lock.json",
173+
"yarn.lock",
174+
"node_modules/lodash/index.js",
175+
"dist/bundle.js.map",
176+
"build/output.css",
177+
# Images
178+
"assets/logo.png",
179+
"static/images/icon.svg",
180+
"design/banner.jpg",
181+
# Layouts / Hugo templates
182+
"Website/layouts/single.html",
183+
"themes/default/_layouts/page.html",
184+
"src/_includes/footer.html",
185+
# CI/CD
186+
".github/workflows/linter.yml",
187+
".github/ISSUE_TEMPLATE.md",
188+
".gitlab-ci.yml",
189+
# Test dirs at varying depths
190+
"tests/test_auth.py",
191+
"application/utils/parser/tests/fixtures/sample.json",
192+
"test/integration/test_api.py",
193+
# SAMM empirical hits
194+
"Supporting Resources/meetings/10012018.md",
195+
"Supporting Resources/enterprise metrics/overview.md",
196+
# Generic web fonts
197+
"static/fonts/Roboto.woff2",
198+
"assets/fonts/code.ttf",
199+
]
200+
201+
KNOWN_DOC_PATHS = [
202+
# WSTG security testing docs
203+
"document/4-Web_Application_Security_Testing/04-Authentication_Testing/01-Testing.md",
204+
"document/4-Web_Application_Security_Testing/07-Input_Validation_Testing/12-Testing_for_Command_Injection.md",
205+
# ASVS appendices and chapters
206+
"5.0/en/0x12-V3-Authentication.md",
207+
"5.0/en/0x92-Appendix-C_Cryptography.md",
208+
# Cheatsheets
209+
"cheatsheets/OAuth2_Cheat_Sheet.md",
210+
"cheatsheets/DOM_based_XSS_Prevention_Cheat_Sheet.md",
211+
# Top-level README that mentions a security project (allowed under recall-first)
212+
"README.md",
213+
# CONTRIBUTING with security@ mailto (mixed signal -- let Stage 2 judge)
214+
"CONTRIBUTING.md",
215+
# SAMM content (organizational but recall-first: let Stage 2 decide)
216+
"Website/content/en/sponsors.md",
217+
"Website/content/en/user-day/_index.md",
218+
]
219+
220+
221+
class RealisticPathTests(unittest.TestCase):
222+
223+
@classmethod
224+
def setUpClass(cls) -> None:
225+
cls.filt = RegexFilter()
226+
227+
def test_rejection_rate_on_known_junk_at_least_90_percent(self) -> None:
228+
"""Plan acceptance criterion: regex catches >=90% of known junk paths."""
229+
rejected = sum(1 for p in KNOWN_JUNK_PATHS if self.filt.is_noise_path(p)[0])
230+
rate = rejected / len(KNOWN_JUNK_PATHS)
231+
# Print details to make failures actionable.
232+
missed = [p for p in KNOWN_JUNK_PATHS if not self.filt.is_noise_path(p)[0]]
233+
self.assertGreaterEqual(
234+
rate,
235+
0.90,
236+
msg=(
237+
f"Junk rejection rate {rate:.0%} below 90%. "
238+
f"Paths missed by regex (would reach Stage 2): {missed}"
239+
),
240+
)
241+
242+
def test_zero_false_positives_on_known_doc_paths(self) -> None:
243+
"""Plan acceptance criterion: no security docs are wrongly rejected."""
244+
false_positives = [p for p in KNOWN_DOC_PATHS if self.filt.is_noise_path(p)[0]]
245+
self.assertEqual(
246+
false_positives,
247+
[],
248+
msg=f"Security doc paths wrongly flagged as noise: {false_positives}",
249+
)
250+
251+
252+
# --- 4. ChangeRecord-level API + generator -------------------------------
253+
254+
255+
class RecordTests(unittest.TestCase):
256+
257+
@classmethod
258+
def setUpClass(cls) -> None:
259+
cls.filt = RegexFilter()
260+
261+
def test_is_noise_record_uses_locator_path(self) -> None:
262+
rec = _record_with_path("frontend/styles/app.css")
263+
is_noise, reason = self.filt.is_noise_record(rec)
264+
self.assertTrue(is_noise)
265+
self.assertIn(".css", reason)
266+
267+
def test_is_noise_record_accepts_security_doc(self) -> None:
268+
rec = _record_with_path(
269+
"document/4-Web_Application_Security_Testing/04-Authentication_Testing/01-Testing.md"
270+
)
271+
is_noise, _ = self.filt.is_noise_record(rec)
272+
self.assertFalse(is_noise)
273+
274+
def test_filter_records_yields_only_accepted(self) -> None:
275+
records = [
276+
_record_with_path("frontend/app.css"),
277+
_record_with_path("document/security/auth.md"),
278+
_record_with_path("tests/test_something.py"),
279+
_record_with_path("cheatsheets/OAuth.md"),
280+
]
281+
accepted = list(self.filt.filter_records(records))
282+
self.assertEqual(len(accepted), 2)
283+
self.assertEqual(
284+
sorted(r.locator.path for r in accepted),
285+
["cheatsheets/OAuth.md", "document/security/auth.md"],
286+
)
287+
288+
def test_filter_records_is_lazy(self) -> None:
289+
"""The generator should not consume the iterable eagerly."""
290+
291+
def gen():
292+
yield _record_with_path("a.css")
293+
raise RuntimeError("filter_records should not pull past the first record")
294+
295+
out = self.filt.filter_records(gen())
296+
# We can advance once without raising -- the .css record is rejected,
297+
# so the generator pulls the next one and hits the RuntimeError.
298+
with self.assertRaises(RuntimeError):
299+
next(out)
300+
301+
302+
if __name__ == "__main__":
303+
unittest.main()

0 commit comments

Comments
 (0)