From f3283fec5c2f5248c1d3c2f7b921072d551e079c Mon Sep 17 00:00:00 2001 From: Mohit Gupta Date: Thu, 10 Sep 2026 16:15:18 +0530 Subject: [PATCH] feat: configure the static analysis time allowance Default to a five-minute per-artifact allowance bounded by the remaining workflow time. Prepared by Codex on behalf of Mohit Gupta. Signed-off-by: Mohit Gupta --- .env.example | 4 + docs/ANALYSIS_RESOURCE_BOUNDS.md | 15 ++- .../nodes/analyzers/static_runner.py | 28 +++++- .../nodes/analyzers/static_yara.py | 2 +- .../test_static_budget_configuration.py | 96 +++++++++++++++++++ tests/nodes/analyzers/test_static_yara.py | 3 +- 6 files changed, 144 insertions(+), 4 deletions(-) create mode 100644 tests/nodes/analyzers/test_static_budget_configuration.py diff --git a/.env.example b/.env.example index 9f560c31e..3291d2c90 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,10 @@ SKILLSPECTOR_PROVIDER= # override this positive finite value when a different limit is required. # SKILLSPECTOR_MAX_WORKFLOW_SECONDS=600 +# Static analysis per artifact defaults to 300 seconds, capped by the remaining +# workflow time. Set a positive finite value before starting the process. +# SKILLSPECTOR_MAX_STATIC_ANALYSIS_SECONDS_PER_ARTIFACT=300 + # Provider credentials — set the one matching SKILLSPECTOR_PROVIDER (or # leave SKILLSPECTOR_PROVIDER unset and set NVIDIA_INFERENCE_KEY for the # default nv_build path). diff --git a/docs/ANALYSIS_RESOURCE_BOUNDS.md b/docs/ANALYSIS_RESOURCE_BOUNDS.md index d5bea8690..f513dcdc4 100644 --- a/docs/ANALYSIS_RESOURCE_BOUNDS.md +++ b/docs/ANALYSIS_RESOURCE_BOUNDS.md @@ -155,7 +155,7 @@ removes the rejected partial checkout. | Build-context ledger events | 10,000 | One bundle context | | Static findings | 10,000 | One artifact | | Static findings | 10,000 | One analyzer | -| Static-analysis time | 30 seconds | One artifact | +| Static-analysis time | 300 seconds | One artifact, within the workflow deadline | | YARA rule-directory entries | 10,000 | Built-in and optional directories combined | | YARA rule files | 1,024 | One rule load | | YARA rule source bytes | 1 MiB | One rule file | @@ -221,3 +221,16 @@ change that aggregate deadline. The setting applies to direct, CLI, recursive, and multi-skill scans; byte and artifact ceilings remain in effect. Invalid, zero, negative, infinite, or NaN values safely keep the 600-second default. + +## Configuring the static analysis deadline + +Static pattern analysis and YARA matching allow up to 300 seconds per artifact by +default. Set `SKILLSPECTOR_MAX_STATIC_ANALYSIS_SECONDS_PER_ARTIFACT` to a positive +finite number of seconds to change that allowance. Invalid, zero, negative, +infinite, or NaN values log a warning and retain the 300-second default. + +Each operation still uses the smaller of this allowance and the remaining workflow +time. Increasing it does not extend the aggregate workflow deadline. A limit +reached during analysis retains existing findings and reports partial work through +the inspection ledger. Both environment settings are read when their modules are +imported, so restart the SkillSpector process after changing them. diff --git a/src/skillspector/nodes/analyzers/static_runner.py b/src/skillspector/nodes/analyzers/static_runner.py index 9e807b48c..355e454d2 100644 --- a/src/skillspector/nodes/analyzers/static_runner.py +++ b/src/skillspector/nodes/analyzers/static_runner.py @@ -17,6 +17,8 @@ from __future__ import annotations +import math +import os import re import time import unicodedata @@ -115,7 +117,31 @@ _CONTINUITY_MAX_CHAIN_RUNS = 24 MAX_FINDINGS_PER_ARTIFACT = 10_000 MAX_FINDINGS_PER_ANALYZER = 10_000 -MAX_STATIC_ANALYSIS_SECONDS_PER_ARTIFACT = 30.0 +DEFAULT_MAX_STATIC_ANALYSIS_SECONDS_PER_ARTIFACT = 300.0 + + +def _static_max_seconds_from_environment(value: str | None) -> float: + """Read the static artifact allowance using the workflow setting's convention.""" + if value is None: + return DEFAULT_MAX_STATIC_ANALYSIS_SECONDS_PER_ARTIFACT + try: + seconds = float(value) + except ValueError: + seconds = 0.0 + if not math.isfinite(seconds) or seconds <= 0: + logger.warning( + "SKILLSPECTOR_MAX_STATIC_ANALYSIS_SECONDS_PER_ARTIFACT=%r must be finite " + "and positive, using default %.1fs", + value, + DEFAULT_MAX_STATIC_ANALYSIS_SECONDS_PER_ARTIFACT, + ) + return DEFAULT_MAX_STATIC_ANALYSIS_SECONDS_PER_ARTIFACT + return seconds + + +MAX_STATIC_ANALYSIS_SECONDS_PER_ARTIFACT = _static_max_seconds_from_environment( + os.environ.get("SKILLSPECTOR_MAX_STATIC_ANALYSIS_SECONDS_PER_ARTIFACT") +) _LICENSE_FILE_TYPES = frozenset({"markdown", "text", "other"}) _LICENSE_BASENAME = re.compile(r"^(?:license|licenses|copying|notice|notices)(?:[._-].*)?$") diff --git a/src/skillspector/nodes/analyzers/static_yara.py b/src/skillspector/nodes/analyzers/static_yara.py index 8f142e50a..ac7b8ecc8 100644 --- a/src/skillspector/nodes/analyzers/static_yara.py +++ b/src/skillspector/nodes/analyzers/static_yara.py @@ -591,7 +591,7 @@ def _match_callback(_match_data: dict[str, object]) -> int: data=data, callback=_match_callback, which_callbacks=yara.CALLBACK_MATCHES, - # Round down so the engine timeout never exceeds min(shared, 30s). + # Round down so the engine timeout never exceeds the effective allowance. timeout=max(1, math.floor(runtime_limit)), # YARA still evaluates full rule conditions, but stops retaining every # repeated string instance after the condition is decided. Without diff --git a/tests/nodes/analyzers/test_static_budget_configuration.py b/tests/nodes/analyzers/test_static_budget_configuration.py new file mode 100644 index 000000000..b9ae511c9 --- /dev/null +++ b/tests/nodes/analyzers/test_static_budget_configuration.py @@ -0,0 +1,96 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Configurable static allowances remain bounded by the parent workflow.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys + +import pytest + +from skillspector.inspection_ledger import LedgerReason +from skillspector.nodes.analyzers import static_runner, static_yara + + +@pytest.mark.parametrize("value", ["", "invalid", "0", "-1", "nan", "inf", "-inf"]) +def test_invalid_static_allowance_warns_and_retains_default(value: str, caplog) -> None: + assert static_runner._static_max_seconds_from_environment(value) == 300.0 + assert "SKILLSPECTOR_MAX_STATIC_ANALYSIS_SECONDS_PER_ARTIFACT" in caplog.text + + +@pytest.mark.parametrize("value,expected", [(None, 300.0), ("45.5", 45.5), ("900", 900.0)]) +def test_fresh_process_shares_configured_allowance_with_yara( + value: str | None, expected: float +) -> None: + env = os.environ.copy() + name = "SKILLSPECTOR_MAX_STATIC_ANALYSIS_SECONDS_PER_ARTIFACT" + env.pop(name, None) + if value is not None: + env[name] = value + result = subprocess.run( + [ + sys.executable, + "-c", + "import json; from skillspector.nodes.analyzers import static_runner, static_yara; " + "print(json.dumps([static_runner.MAX_STATIC_ANALYSIS_SECONDS_PER_ARTIFACT, " + "static_yara.MAX_STATIC_ANALYSIS_SECONDS_PER_ARTIFACT]))", + ], + env=env, + text=True, + capture_output=True, + check=True, + ) + assert json.loads(result.stdout) == [expected, expected] + + +@pytest.mark.parametrize( + "configured,parent,limited", [(300.0, 600.0, False), (20.0, 600.0, True), (300.0, 20.0, True)] +) +def test_static_work_beyond_thirty_seconds_respects_effective_allowance( + monkeypatch: pytest.MonkeyPatch, configured: float, parent: float, limited: bool +) -> None: + now = 0.0 + + class SlowModule: + ANALYZER_ID = "static_tool_misuse" + + @staticmethod + def analyze(**_kwargs): + nonlocal now + now = 31.0 + return [] + + monkeypatch.setattr(static_runner, "MAX_STATIC_ANALYSIS_SECONDS_PER_ARTIFACT", configured) + monkeypatch.setattr(static_runner.time, "monotonic", lambda: now) + findings, reason, metrics = static_runner._scan_all_views_detailed( + "example.txt", "ordinary text", [SlowModule], None, timeout_seconds=parent + ) + assert findings == [] + assert reason == (LedgerReason.RUNTIME_LIMIT if limited else None) + if limited: + assert metrics == {"observed_seconds": 31.0, "limit_seconds": 20.0} + + +@pytest.mark.parametrize( + "configured,parent,expected", [(300.0, 600.0, 300), (45.5, 600.0, 45), (300.0, 42.5, 42)] +) +def test_yara_engine_receives_effective_allowance( + monkeypatch: pytest.MonkeyPatch, configured: float, parent: float, expected: int +) -> None: + calls = [] + + class RecordingRules: + def match(self, **kwargs): + calls.append(kwargs) + return [] + + monkeypatch.setattr(static_yara, "MAX_STATIC_ANALYSIS_SECONDS_PER_ARTIFACT", configured) + result = static_yara._match_file( + RecordingRules(), "ordinary text", "example.txt", timeout_seconds=parent, clock=lambda: 0.0 + ) + assert result.reason is None + assert calls[0]["timeout"] == expected diff --git a/tests/nodes/analyzers/test_static_yara.py b/tests/nodes/analyzers/test_static_yara.py index ccee96429..2128dd8af 100644 --- a/tests/nodes/analyzers/test_static_yara.py +++ b/tests/nodes/analyzers/test_static_yara.py @@ -931,6 +931,7 @@ def match(self, **_kwargs): def test_yara_uses_fast_match_mode_and_engine_timeout(self, monkeypatch) -> None: calls = [] + monkeypatch.setattr(static_yara, "MAX_STATIC_ANALYSIS_SECONDS_PER_ARTIFACT", 300.0) class RecordingRules: def match(self, **kwargs): @@ -944,7 +945,7 @@ def match(self, **kwargs): assert result["inspection_ledger"][0]["outcome"] == "completed" assert calls[0]["fast"] is True - assert calls[0]["timeout"] == 30 + assert calls[0]["timeout"] == 300 assert callable(calls[0]["callback"]) def test_expired_shared_deadline_accounts_for_every_unstarted_path(self, monkeypatch) -> None: