diff --git a/docs/usage.md b/docs/usage.md index 38e0326..19a3c2d 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -284,6 +284,24 @@ first, so the result is the same whatever `N`. coretrace-python-analyzer --check src/ --cache .coretrace --jobs 4 ``` +## Performance + +Measured on one core of a laptop, single process, cold start, with the bundled plugins +(September 2026): + +| Project | Python lines | Functions | Time | Peak memory | +|---|---:|---:|---:|---:| +| healthchecks (Django application) | 45 000 | 2 566 | 7 s | 107 MB | +| rich (library) | 52 000 | 1 198 | 6 s | 106 MB | +| wagtail (Django CMS) | 273 000 | 12 146 | 85 s | 461 MB | + +Time grows a little faster than linearly with the size of the project, because the +interprocedural summaries are iterated to a fixpoint over the module graph. A warm +`--cache` brings an unchanged healthchecks to about one second, `--jobs 4` saves a +quarter of the cold time at the cost of one process's memory per job. The +`healthchecks` repository is part of the regression corpus, so the analysis time of a +real 45 000-line project is exercised on every change. + ## Plugins The package ships 26 plugins, loaded by default. Security models for the standard library diff --git a/src/coretrace_python/ir/lowering.py b/src/coretrace_python/ir/lowering.py index 47a686c..1b2722d 100644 --- a/src/coretrace_python/ir/lowering.py +++ b/src/coretrace_python/ir/lowering.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections import OrderedDict from dataclasses import dataclass, field from typing import ClassVar, NoReturn @@ -507,10 +508,28 @@ def compute(cls, ctx: AnalysisContext, function: nodes.Function) -> FunctionIR: return lowerer.function(function) +_FUNCTIONS_CACHE: OrderedDict[int, tuple[nodes.Module, tuple[nodes.Function, ...]]] = OrderedDict() +_FUNCTIONS_CACHE_SIZE = 32 + + def analyzable_functions(module: nodes.Module) -> tuple[nodes.Function, ...]: """Top-level functions, the methods of top-level classes, and the functions and - lambdas nested inside them, in source order.""" - + lambdas nested inside them, in source order. Computed once per module: the result is + memoised for the last few modules seen, keyed by identity, so every consumer shares + one tuple and the synthesized lambda functions it holds.""" + + cached = _FUNCTIONS_CACHE.get(id(module)) + if cached is not None and cached[0] is module: + _FUNCTIONS_CACHE.move_to_end(id(module)) + return cached[1] + functions = _analyzable_functions(module) + _FUNCTIONS_CACHE[id(module)] = (module, functions) + while len(_FUNCTIONS_CACHE) > _FUNCTIONS_CACHE_SIZE: + _FUNCTIONS_CACHE.popitem(last=False) + return functions + + +def _analyzable_functions(module: nodes.Module) -> tuple[nodes.Function, ...]: functions: list[nodes.Function] = [] for statement in module.body: if isinstance(statement, nodes.Function): diff --git a/src/coretrace_python/taint/engine.py b/src/coretrace_python/taint/engine.py index 518ecaa..d77b873 100644 --- a/src/coretrace_python/taint/engine.py +++ b/src/coretrace_python/taint/engine.py @@ -541,23 +541,25 @@ def local_instances(function: nodes.Function, scopes: ScopeTable, symbols: Symbo def _enclosing(module: nodes.Module, function: nodes.Function) -> nodes.Function | None: """The function whose body defines ``function``, if it is nested.""" - def search(body: tuple[nodes.Statement, ...], parent: nodes.Function | None) -> nodes.Function | None: + def search( + body: tuple[nodes.Statement, ...], parent: nodes.Function | None + ) -> tuple[bool, nodes.Function | None]: for statement in body: if isinstance(statement, nodes.Function): if statement.span == function.span: - return parent + return True, parent found = search(statement.body, statement) - if found is not None: + if found[0]: return found elif isinstance(statement, nodes.Class): found = search(statement.body, None) - if found is not None: + if found[0]: return found - return None + return False, None - found = search(module.body, None) - if found is not None: - return found + defined, parent = search(module.body, None) + if defined: + return parent # Lambdas are synthesized functions: their enclosing function is the innermost # analysable function whose span contains theirs. innermost: nodes.Function | None = None diff --git a/tests/regression/expected/healthchecks.json b/tests/regression/expected/healthchecks.json new file mode 100644 index 0000000..8cb76fa --- /dev/null +++ b/tests/regression/expected/healthchecks.json @@ -0,0 +1,910 @@ +{ + "coverage": { + "files": 653, + "files_analysed": 636, + "functions": 2566, + "functions_analysed": 2566 + }, + "findings": [ + { + "file": "docker/.env.example", + "line": 7, + "rule": "hardcoded-credential", + "function": null + }, + { + "file": "docker/docker-compose.yml", + "line": 11, + "rule": "hardcoded-credential", + "function": null + }, + { + "file": "hc/accounts/decorators.py", + "line": 44, + "rule": "open-redirect", + "function": "wrapper" + }, + { + "file": "hc/accounts/middleware.py", + "line": 1, + "rule": "syntax-error", + "function": null + }, + { + "file": "hc/accounts/migrations/0049_convert_email_lowercase.py", + "line": 1, + "rule": "syntax-error", + "function": null + }, + { + "file": "hc/accounts/models.py", + "line": 1, + "rule": "syntax-error", + "function": null + }, + { + "file": "hc/accounts/tests/test_createsuperuser.py", + "line": 40, + "rule": "hardcoded-credential", + "function": "test_it_accepts_arguments" + }, + { + "file": "hc/accounts/tests/test_login.py", + "line": 90, + "rule": "hardcoded-credential", + "function": "test_it_rate_limits_emails" + }, + { + "file": "hc/accounts/tests/test_login.py", + "line": 151, + "rule": "hardcoded-credential", + "function": "test_it_rate_limits_password_attempts" + }, + { + "file": "hc/accounts/tests/test_login.py", + "line": 187, + "rule": "hardcoded-credential", + "function": "test_it_handles_wrong_password" + }, + { + "file": "hc/accounts/tests/test_project.py", + "line": 224, + "rule": "hardcoded-credential", + "function": "test_it_rate_limits_invites" + }, + { + "file": "hc/accounts/tests/test_set_password.py", + "line": 24, + "rule": "hardcoded-credential", + "function": "test_it_sets_password" + }, + { + "file": "hc/accounts/tests/test_signup.py", + "line": 147, + "rule": "hardcoded-credential", + "function": "test_it_rate_limits_email_address" + }, + { + "file": "hc/accounts/views.py", + "line": 1, + "rule": "syntax-error", + "function": null + }, + { + "file": "hc/api/management/commands/sendalerts.py", + "line": 1, + "rule": "syntax-error", + "function": null + }, + { + "file": "hc/api/models.py", + "line": 434, + "rule": "weak-crypto", + "function": "unique_key" + }, + { + "file": "hc/api/models.py", + "line": 1489, + "rule": "weak-crypto", + "function": "authorize_login_email" + }, + { + "file": "hc/api/models.py", + "line": 1504, + "rule": "weak-crypto", + "function": "authorize_login_password" + }, + { + "file": "hc/api/models.py", + "line": 1517, + "rule": "weak-crypto", + "function": "authorize_signal" + }, + { + "file": "hc/api/models.py", + "line": 1532, + "rule": "weak-crypto", + "function": "authorize_pushover" + }, + { + "file": "hc/api/models.py", + "line": 1540, + "rule": "weak-crypto", + "function": "authorize_ntfy" + }, + { + "file": "hc/api/tests/test_bounces.py", + "line": 121, + "rule": "hardcoded-credential", + "function": "test_it_handles_bad_signature" + }, + { + "file": "hc/api/tests/test_tokenbucket.py", + "line": 12, + "rule": "high-entropy-string", + "function": null + }, + { + "file": "hc/api/tests/test_tokenbucket.py", + "line": 15, + "rule": "hardcoded-credential", + "function": null + }, + { + "file": "hc/api/transports.py", + "line": 1, + "rule": "syntax-error", + "function": null + }, + { + "file": "hc/api/views.py", + "line": 1, + "rule": "syntax-error", + "function": null + }, + { + "file": "hc/front/tests/test_webhook_validator.py", + "line": 22, + "rule": "hardcoded-secret", + "function": "test_it_does_not_touch_existing_tld_with_port_and_basic_auth" + }, + { + "file": "hc/front/tests/test_webhook_validator.py", + "line": 23, + "rule": "hardcoded-secret", + "function": "test_it_does_not_touch_existing_tld_with_port_and_basic_auth" + }, + { + "file": "hc/front/tests/test_webhook_validator.py", + "line": 39, + "rule": "hardcoded-secret", + "function": "test_it_handles_port_and_basic_auth" + }, + { + "file": "hc/front/tests/test_webhook_validator.py", + "line": 40, + "rule": "hardcoded-secret", + "function": "test_it_handles_port_and_basic_auth" + }, + { + "file": "hc/front/views.py", + "line": 553, + "rule": "open-redirect", + "function": "add_check" + }, + { + "file": "hc/front/views.py", + "line": 570, + "rule": "open-redirect", + "function": "update_name" + }, + { + "file": "hc/front/views.py", + "line": 574, + "rule": "open-redirect", + "function": "update_name" + }, + { + "file": "hc/front/views.py", + "line": 599, + "rule": "open-redirect", + "function": "filtering_rules" + }, + { + "file": "hc/front/views.py", + "line": 666, + "rule": "open-redirect", + "function": "update_timeout" + }, + { + "file": "hc/front/views.py", + "line": 670, + "rule": "open-redirect", + "function": "update_timeout" + }, + { + "file": "hc/front/views.py", + "line": 814, + "rule": "xss", + "function": "ping_body" + }, + { + "file": "hc/front/views.py", + "line": 827, + "rule": "open-redirect", + "function": "pause" + }, + { + "file": "hc/front/views.py", + "line": 845, + "rule": "open-redirect", + "function": "pause" + }, + { + "file": "hc/front/views.py", + "line": 863, + "rule": "open-redirect", + "function": "resume" + }, + { + "file": "hc/front/views.py", + "line": 873, + "rule": "open-redirect", + "function": "remove_check" + }, + { + "file": "hc/front/views.py", + "line": 902, + "rule": "open-redirect", + "function": "clear_events" + }, + { + "file": "hc/front/views.py", + "line": 1044, + "rule": "open-redirect", + "function": "uncloak" + }, + { + "file": "hc/front/views.py", + "line": 1068, + "rule": "open-redirect", + "function": "transfer" + }, + { + "file": "hc/front/views.py", + "line": 1115, + "rule": "open-redirect", + "function": "copy" + }, + { + "file": "hc/front/views.py", + "line": 1230, + "rule": "open-redirect", + "function": "channels" + }, + { + "file": "hc/front/views.py", + "line": 1297, + "rule": "open-redirect", + "function": "update_channel_name" + }, + { + "file": "hc/front/views.py", + "line": 1334, + "rule": "open-redirect", + "function": "send_test_notification" + }, + { + "file": "hc/front/views.py", + "line": 1344, + "rule": "open-redirect", + "function": "remove_channel" + }, + { + "file": "hc/front/views.py", + "line": 1353, + "rule": "open-redirect", + "function": "edit_channel" + }, + { + "file": "hc/front/views.py", + "line": 1357, + "rule": "open-redirect", + "function": "edit_channel" + }, + { + "file": "hc/front/views.py", + "line": 1361, + "rule": "open-redirect", + "function": "edit_channel" + }, + { + "file": "hc/front/views.py", + "line": 1365, + "rule": "open-redirect", + "function": "edit_channel" + }, + { + "file": "hc/front/views.py", + "line": 1369, + "rule": "open-redirect", + "function": "edit_channel" + }, + { + "file": "hc/front/views.py", + "line": 1373, + "rule": "open-redirect", + "function": "edit_channel" + }, + { + "file": "hc/front/views.py", + "line": 1377, + "rule": "open-redirect", + "function": "edit_channel" + }, + { + "file": "hc/front/views.py", + "line": 1381, + "rule": "open-redirect", + "function": "edit_channel" + }, + { + "file": "hc/integrations/apprise/views.py", + "line": 31, + "rule": "open-redirect", + "function": "add" + }, + { + "file": "hc/integrations/call/views.py", + "line": 30, + "rule": "open-redirect", + "function": "add" + }, + { + "file": "hc/integrations/discord/tests/test_add_complete.py", + "line": 24, + "rule": "hardcoded-credential", + "function": "test_it_handles_oauth_response" + }, + { + "file": "hc/integrations/discord/views.py", + "line": 57, + "rule": "open-redirect", + "function": "add_complete" + }, + { + "file": "hc/integrations/discord/views.py", + "line": 75, + "rule": "open-redirect", + "function": "add_complete" + }, + { + "file": "hc/integrations/discord/views.py", + "line": 83, + "rule": "open-redirect", + "function": "add_complete" + }, + { + "file": "hc/integrations/discord/views.py", + "line": 90, + "rule": "open-redirect", + "function": "add_complete" + }, + { + "file": "hc/integrations/email/views.py", + "line": 72, + "rule": "open-redirect", + "function": "add" + }, + { + "file": "hc/integrations/github/tests/test_add_save.py", + "line": 20, + "rule": "hardcoded-credential", + "function": "setUp" + }, + { + "file": "hc/integrations/github/tests/test_add_select.py", + "line": 49, + "rule": "hardcoded-credential", + "function": "test_it_skips_oauth_code_exchange" + }, + { + "file": "hc/integrations/github/tests/test_add_select.py", + "line": 71, + "rule": "hardcoded-credential", + "function": "test_it_requires_rw_access" + }, + { + "file": "hc/integrations/github/tests/test_add_select.py", + "line": 109, + "rule": "hardcoded-credential", + "function": "test_it_redirects_to_install_page" + }, + { + "file": "hc/integrations/github/tests/test_add_select.py", + "line": 154, + "rule": "hardcoded-credential", + "function": "test_it_handles_bad_credentials" + }, + { + "file": "hc/integrations/github/tests/test_notify.py", + "line": 18, + "rule": "hardcoded-credential", + "function": null + }, + { + "file": "hc/integrations/github/views.py", + "line": 61, + "rule": "open-redirect", + "function": "select" + }, + { + "file": "hc/integrations/github/views.py", + "line": 79, + "rule": "open-redirect", + "function": "select" + }, + { + "file": "hc/integrations/github/views.py", + "line": 113, + "rule": "open-redirect", + "function": "save" + }, + { + "file": "hc/integrations/github/views.py", + "line": 113, + "rule": "open-redirect", + "function": "save" + }, + { + "file": "hc/integrations/github/views.py", + "line": 132, + "rule": "open-redirect", + "function": "save" + }, + { + "file": "hc/integrations/github/views.py", + "line": 132, + "rule": "open-redirect", + "function": "save" + }, + { + "file": "hc/integrations/googlechat/tests/test_notify.py", + "line": 1, + "rule": "syntax-error", + "function": null + }, + { + "file": "hc/integrations/googlechat/transport.py", + "line": 1, + "rule": "syntax-error", + "function": null + }, + { + "file": "hc/integrations/googlechat/views.py", + "line": 33, + "rule": "open-redirect", + "function": "add" + }, + { + "file": "hc/integrations/gotify/tests/test_edit.py", + "line": 18, + "rule": "hardcoded-credential", + "function": "setUp" + }, + { + "file": "hc/integrations/gotify/tests/test_edit.py", + "line": 37, + "rule": "hardcoded-credential", + "function": "test_it_updates_channel" + }, + { + "file": "hc/integrations/gotify/tests/test_notify.py", + "line": 50, + "rule": "hardcoded-secret", + "function": "test_it_works" + }, + { + "file": "hc/integrations/gotify/tests/test_notify.py", + "line": 93, + "rule": "hardcoded-secret", + "function": "test_it_handles_subpath" + }, + { + "file": "hc/integrations/gotify/tests/test_notify.py", + "line": 105, + "rule": "hardcoded-secret", + "function": "test_it_handles_subpath_with_missing_slash" + }, + { + "file": "hc/integrations/gotify/views.py", + "line": 26, + "rule": "open-redirect", + "function": "gotify_form" + }, + { + "file": "hc/integrations/gotify/views.py", + "line": 54, + "rule": "open-redirect", + "function": "add" + }, + { + "file": "hc/integrations/group/views.py", + "line": 44, + "rule": "open-redirect", + "function": "add" + }, + { + "file": "hc/integrations/matrix/tests/test_notify.py", + "line": 15, + "rule": "hardcoded-credential", + "function": null + }, + { + "file": "hc/integrations/matrix/views.py", + "line": 37, + "rule": "open-redirect", + "function": "add" + }, + { + "file": "hc/integrations/mattermost/views.py", + "line": 34, + "rule": "open-redirect", + "function": "add" + }, + { + "file": "hc/integrations/msteamsw/transport.py", + "line": 1, + "rule": "syntax-error", + "function": null + }, + { + "file": "hc/integrations/msteamsw/views.py", + "line": 29, + "rule": "open-redirect", + "function": "add_msteams" + }, + { + "file": "hc/integrations/ntfy/tests/test_add.py", + "line": 78, + "rule": "hardcoded-credential", + "function": "test_it_saves_token" + }, + { + "file": "hc/integrations/ntfy/tests/test_edit.py", + "line": 21, + "rule": "hardcoded-credential", + "function": "setUp" + }, + { + "file": "hc/integrations/ntfy/tests/test_edit.py", + "line": 71, + "rule": "hardcoded-credential", + "function": "test_it_updates_token" + }, + { + "file": "hc/integrations/ntfy/tests/test_notify.py", + "line": 222, + "rule": "hardcoded-credential", + "function": "test_it_uses_access_token" + }, + { + "file": "hc/integrations/ntfy/tests/test_notify.py", + "line": 233, + "rule": "hardcoded-credential", + "function": "test_it_obeys_rate_limit" + }, + { + "file": "hc/integrations/ntfy/tests/test_notify.py", + "line": 245, + "rule": "hardcoded-credential", + "function": "test_it_uses_default_ntfy_sh_token" + }, + { + "file": "hc/integrations/ntfy/tests/test_notify.py", + "line": 264, + "rule": "hardcoded-credential", + "function": "test_uses_default_token_only_on_ntfy_sh" + }, + { + "file": "hc/integrations/ntfy/views.py", + "line": 25, + "rule": "open-redirect", + "function": "ntfy_form" + }, + { + "file": "hc/integrations/ntfy/views.py", + "line": 47, + "rule": "open-redirect", + "function": "add" + }, + { + "file": "hc/integrations/opsgenie/transport.py", + "line": 1, + "rule": "syntax-error", + "function": null + }, + { + "file": "hc/integrations/opsgenie/views.py", + "line": 31, + "rule": "open-redirect", + "function": "add" + }, + { + "file": "hc/integrations/pagertree/views.py", + "line": 32, + "rule": "open-redirect", + "function": "add" + }, + { + "file": "hc/integrations/pd/transport.py", + "line": 1, + "rule": "syntax-error", + "function": null + }, + { + "file": "hc/integrations/pd/views.py", + "line": 52, + "rule": "open-redirect", + "function": "add" + }, + { + "file": "hc/integrations/pd/views.py", + "line": 85, + "rule": "open-redirect", + "function": "add_complete" + }, + { + "file": "hc/integrations/po/tests/test_notify.py", + "line": 15, + "rule": "hardcoded-credential", + "function": null + }, + { + "file": "hc/integrations/po/tests/test_notify.py", + "line": 146, + "rule": "hardcoded-credential", + "function": "test_it_obeys_rate_limit" + }, + { + "file": "hc/integrations/po/views.py", + "line": 59, + "rule": "open-redirect", + "function": "add" + }, + { + "file": "hc/integrations/po/views.py", + "line": 73, + "rule": "open-redirect", + "function": "add" + }, + { + "file": "hc/integrations/po/views.py", + "line": 85, + "rule": "open-redirect", + "function": "add" + }, + { + "file": "hc/integrations/pushbullet/tests/test_add_complete.py", + "line": 22, + "rule": "hardcoded-credential", + "function": "test_it_handles_oauth_response" + }, + { + "file": "hc/integrations/pushbullet/views.py", + "line": 66, + "rule": "open-redirect", + "function": "add_complete" + }, + { + "file": "hc/integrations/pushbullet/views.py", + "line": 86, + "rule": "open-redirect", + "function": "add_complete" + }, + { + "file": "hc/integrations/pushbullet/views.py", + "line": 93, + "rule": "open-redirect", + "function": "add_complete" + }, + { + "file": "hc/integrations/rocketchat/transport.py", + "line": 1, + "rule": "syntax-error", + "function": null + }, + { + "file": "hc/integrations/rocketchat/views.py", + "line": 34, + "rule": "open-redirect", + "function": "add" + }, + { + "file": "hc/integrations/shell/views.py", + "line": 28, + "rule": "open-redirect", + "function": "add" + }, + { + "file": "hc/integrations/signal/tests/test_notify.py", + "line": 273, + "rule": "hardcoded-credential", + "function": "test_it_obeys_rate_limit" + }, + { + "file": "hc/integrations/signal/tests/test_notify.py", + "line": 483, + "rule": "hardcoded-credential", + "function": "test_it_handles_rate_limit_failure" + }, + { + "file": "hc/integrations/signal/tests/test_verify_signal_number.py", + "line": 71, + "rule": "hardcoded-credential", + "function": "test_it_obeys_per_recipient_rate_limit" + }, + { + "file": "hc/integrations/signal/views.py", + "line": 34, + "rule": "open-redirect", + "function": "signal_form" + }, + { + "file": "hc/integrations/signal/views.py", + "line": 61, + "rule": "open-redirect", + "function": "add_signal" + }, + { + "file": "hc/integrations/slack/transport.py", + "line": 1, + "rule": "syntax-error", + "function": null + }, + { + "file": "hc/integrations/slack/views.py", + "line": 37, + "rule": "open-redirect", + "function": "add" + }, + { + "file": "hc/integrations/slack/views.py", + "line": 93, + "rule": "open-redirect", + "function": "add_complete" + }, + { + "file": "hc/integrations/slack/views.py", + "line": 112, + "rule": "open-redirect", + "function": "add_complete" + }, + { + "file": "hc/integrations/slack/views.py", + "line": 122, + "rule": "open-redirect", + "function": "add_complete" + }, + { + "file": "hc/integrations/sms/views.py", + "line": 29, + "rule": "open-redirect", + "function": "sms_form" + }, + { + "file": "hc/integrations/sms/views.py", + "line": 58, + "rule": "open-redirect", + "function": "add" + }, + { + "file": "hc/integrations/spike/views.py", + "line": 29, + "rule": "open-redirect", + "function": "add_spike" + }, + { + "file": "hc/integrations/telegram/tests/test_add.py", + "line": 13, + "rule": "hardcoded-credential", + "function": null + }, + { + "file": "hc/integrations/telegram/views.py", + "line": 1, + "rule": "syntax-error", + "function": null + }, + { + "file": "hc/integrations/trello/tests/test_notify.py", + "line": 36, + "rule": "hardcoded-credential", + "function": "setUp" + }, + { + "file": "hc/integrations/trello/views.py", + "line": 39, + "rule": "open-redirect", + "function": "add" + }, + { + "file": "hc/integrations/victorops/views.py", + "line": 29, + "rule": "open-redirect", + "function": "add" + }, + { + "file": "hc/integrations/webhook/tests/test_add.py", + "line": 79, + "rule": "hardcoded-secret", + "function": "test_it_accepts_good_urls" + }, + { + "file": "hc/integrations/webhook/tests/test_add.py", + "line": 80, + "rule": "hardcoded-secret", + "function": "test_it_accepts_good_urls" + }, + { + "file": "hc/integrations/webhook/views.py", + "line": 30, + "rule": "open-redirect", + "function": "webhook_form" + }, + { + "file": "hc/integrations/webhook/views.py", + "line": 58, + "rule": "open-redirect", + "function": "add" + }, + { + "file": "hc/integrations/whatsapp/views.py", + "line": 28, + "rule": "open-redirect", + "function": "whatsapp_form" + }, + { + "file": "hc/integrations/whatsapp/views.py", + "line": 56, + "rule": "open-redirect", + "function": "add" + }, + { + "file": "hc/integrations/zulip/tests/test_add.py", + "line": 12, + "rule": "hardcoded-credential", + "function": "_get_payload" + }, + { + "file": "hc/integrations/zulip/tests/test_notify.py", + "line": 46, + "rule": "hardcoded-credential", + "function": "definition" + }, + { + "file": "hc/integrations/zulip/tests/test_notify.py", + "line": 133, + "rule": "hardcoded-credential", + "function": "test_it_uses_site_parameter" + }, + { + "file": "hc/integrations/zulip/views.py", + "line": 29, + "rule": "open-redirect", + "function": "add" + }, + { + "file": "hc/settings.py", + "line": 1, + "rule": "syntax-error", + "function": null + }, + { + "file": "hc/urls.py", + "line": 1, + "rule": "syntax-error", + "function": null + } + ] +} diff --git a/tests/regression/repositories.toml b/tests/regression/repositories.toml index 34a8ca5..98d2a9d 100644 --- a/tests/regression/repositories.toml +++ b/tests/regression/repositories.toml @@ -63,6 +63,11 @@ name = "yt-fts" url = "https://github.com/NotJoeMartinez/yt-fts.git" commit = "7fbc0088f30672b6fbfe2ea7351fa42bafe1f886" +[[repository]] +name = "healthchecks" +url = "https://github.com/healthchecks/healthchecks.git" +commit = "69dbd2a0801dfbcfcd554af8cbbdaaab0b39e2a3" + # Intentionally vulnerable Flask fixture kept in the tree. [[repository]] name = "vulnerable-flask" diff --git a/tests/test_scale.py b/tests/test_scale.py new file mode 100644 index 0000000..641e4fb --- /dev/null +++ b/tests/test_scale.py @@ -0,0 +1,69 @@ +"""Acceptance tests for the hot spots found on large projects (issue #69). + +Profiling a 45 000-line project showed the taint engine re-collecting every analysable +function of the module for every function it analysed: the search for the enclosing +function fell through to the lambda fallback for every top-level function, and the +collection itself was never memoised. The list of a module's analysable functions is +computed once per module, and the enclosing-function search only walks the module for +lambdas. + +Expected to remain red until ``analyzable_functions`` is memoised per module. +""" + +from __future__ import annotations + +import pytest + +from coretrace_python.frontend import build_hir +from coretrace_python.ir.lowering import analyzable_functions +from coretrace_python.source import SourceManager +from coretrace_python.taint import engine as taint_engine + +SOURCE = ( + "def top(x):\n" + " return x\n" + "\n" + "def outer(y):\n" + " def inner(z):\n" + " return z\n" + " f = lambda q: q\n" + " return inner(y)\n" + "\n" + "class C:\n" + " def method(self):\n" + " return 1\n" +) + + +def module(): # type: ignore[no-untyped-def] + return build_hir(SourceManager().add_source("m.py", SOURCE)) + + +def test_the_functions_of_a_module_are_collected_once() -> None: + m = module() + first = analyzable_functions(m) + + assert analyzable_functions(m) is first + assert [f.name for f in first] == ["top", "outer", "inner", "lambda_7_9", "method"] + assert analyzable_functions(module()) is not first + + +def test_enclosing_function_of_a_named_function_does_not_walk_the_module(monkeypatch: pytest.MonkeyPatch) -> None: + m = module() + top, outer, inner, _lam, method = analyzable_functions(m) + + def forbidden(*args: object) -> object: + raise AssertionError("analyzable_functions must not be called for named functions") + + monkeypatch.setattr(taint_engine, "analyzable_functions", forbidden) + + assert taint_engine._enclosing(m, top) is None + assert taint_engine._enclosing(m, method) is None + assert taint_engine._enclosing(m, inner) is outer + + +def test_enclosing_function_of_a_lambda_is_the_innermost_function_around_it() -> None: + m = module() + _top, outer, _inner, lam, _method = analyzable_functions(m) + + assert taint_engine._enclosing(m, lam) is outer