Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,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).
Expand Down
15 changes: 14 additions & 1 deletion docs/ANALYSIS_RESOURCE_BOUNDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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.
28 changes: 27 additions & 1 deletion src/skillspector/nodes/analyzers/static_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@

from __future__ import annotations

import math
import os
import re
import time
import unicodedata
Expand Down Expand Up @@ -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)(?:[._-].*)?$")
Expand Down
2 changes: 1 addition & 1 deletion src/skillspector/nodes/analyzers/static_yara.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
96 changes: 96 additions & 0 deletions tests/nodes/analyzers/test_static_budget_configuration.py
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion tests/nodes/analyzers/test_static_yara.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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:
Expand Down
Loading