diff --git a/CHANGELOG.md b/CHANGELOG.md index ff4fe77..f66f6ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - Two plugins may declare the same symbol: an identical model is registered once and sinks merge their kinds and positions; a real conflict names both plugins instead of failing the run on the first duplicate (#92). +### Analysis + +- New taint kinds: `NOSQL` in `TaintKind.ALL`; `LOG` and `PII` outside it, carried only by the values a model marks, so a NoSQL-injection, log-injection or personal-data detector no longer has to share a kind with another rule (#96). + ## 0.2.0 (2026-09-06) ### Adoption in an existing repository diff --git a/docs/plugins.md b/docs/plugins.md index 02f2488..58192f1 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -101,10 +101,14 @@ class CommandInjectionPlugin(TaintDetector): ``` `confidence` defaults to high; a hotspot, a flow behind a guard that mentions the value -or an authorization decorator, is reported at medium. The taint kinds are `SQL`, -`COMMAND`, `HTML`, `PATH`, `SSRF`, `CODE`, `DESERIALIZATION`, `REDIRECT`, `ADVISORY` -(APIs affected by advisories) and `CREDENTIAL`, which only credential-named parameters -carry so that ordinary input reaching a database write is not a plaintext credential. +or an authorization decorator, is reported at medium. The taint kinds every source +carries by default (`TaintKind.ALL`) are `SQL`, `NOSQL`, `COMMAND`, `HTML`, `PATH`, +`SSRF`, `CODE`, `DESERIALIZATION`, `REDIRECT` and `ADVISORY` (APIs affected by +advisories). Three kinds are outside `ALL`, so only a value a model marks with them +reaches their sinks: `CREDENTIAL`, which credential-named parameters carry so that +ordinary input reaching a database write is not a plaintext credential; `LOG`, for +values that must not be written to a log as they are; and `PII`, for personal data +that must not leave the application through a log, a third party or plain storage. ### Security models: `ModelPlugin` diff --git a/src/coretrace_python/taint/models.py b/src/coretrace_python/taint/models.py index 10572f1..cf903e1 100644 --- a/src/coretrace_python/taint/models.py +++ b/src/coretrace_python/taint/models.py @@ -30,10 +30,14 @@ class TaintKind(Flag): ADVISORY = auto() DESERIALIZATION = auto() REDIRECT = auto() - ALL = SQL | COMMAND | HTML | PATH | SSRF | CODE | ADVISORY | DESERIALIZATION | REDIRECT - # Outside ALL on purpose: only credential-named parameters carry it, so a database - # write reached by ordinary input is not a plaintext credential. + NOSQL = auto() + ALL = SQL | COMMAND | HTML | PATH | SSRF | CODE | ADVISORY | DESERIALIZATION | REDIRECT | NOSQL + # Outside ALL on purpose: only the values a model marks carry them, so a database + # write reached by ordinary input is not a plaintext credential, and ordinary input + # reaching a logger is neither log forging nor a personal-data leak. CREDENTIAL = auto() + LOG = auto() + PII = auto() class ModelError(Exception): diff --git a/tests/test_taint.py b/tests/test_taint.py index 00e4eac..7867917 100644 --- a/tests/test_taint.py +++ b/tests/test_taint.py @@ -101,6 +101,38 @@ def test_taint_kinds_form_a_bitset() -> None: assert {TaintKind.SQL, TaintKind.COMMAND, TaintKind.HTML, TaintKind.PATH, TaintKind.SSRF} <= set(TaintKind) +def test_nosql_is_ordinary_input_while_log_and_pii_are_opt_in() -> None: + assert TaintKind.NOSQL in TaintKind.ALL + assert TaintKind.LOG not in TaintKind.ALL + assert TaintKind.PII not in TaintKind.ALL + assert TaintKind.CREDENTIAL not in TaintKind.ALL + + +def test_ordinary_input_reaches_a_nosql_sink_but_not_a_log_sink() -> None: + registry = SecurityModelRegistry() + registry.register( + Source(sym("python.flask.request.args"), HTTP), + Source(sym("python.app.record"), "record", TaintKind.LOG), + Sink(sym("python.db.find"), TaintKind.NOSQL), + Sink(sym("python.logging.info"), TaintKind.LOG), + ) + _, facts = analyze( + "from flask import request\n" + "import db, logging\n" + "from app import record\n\n" + "def run():\n" + " db.find(request.args['q'])\n" + " logging.info(request.args['q'])\n" + " logging.info(record())\n", + registry.freeze(), + ) + + assert [(f.sink.symbol, f.kinds) for f in facts.flows] == [ + (sym("python.db.find"), TaintKind.NOSQL), + (sym("python.logging.info"), TaintKind.LOG), + ] + + def test_taint_joins_with_or() -> None: a = Taint(TaintKind.SQL, frozenset({Source(sym("python.a"), "a")})) b = Taint(TaintKind.HTML, frozenset({Source(sym("python.b"), "b")}))