Skip to content

fix(static_yara): surface dropped rule files instead of reporting completed - #557

Open
Souptik96 wants to merge 2 commits into
NVIDIA:mainfrom
Souptik96:fix/554-yara-rule-skip-visibility
Open

Souptik96 wants to merge 2 commits into
NVIDIA:mainfrom
Souptik96:fix/554-yara-rule-skip-visibility

Conversation

@Souptik96

Copy link
Copy Markdown

Fixes #554

What was wrong

A rule file passed through --yara-rules-dir that YARA cannot compile, or that SkillSpector cannot decode as UTF-8/base64, is dropped whole with only debug-level logging. _load_rules already counted these (materialize_skipped + compile_skipped), but only logged the total — node() never saw it, so every scanned component could still report COMPLETED, analysis_completeness: complete, and the recommendation stayed SAFE, because the rule that would have flagged something simply never ran. --fail-on-incomplete correctly has nothing to key off, so it exits 0.

Reproduced with the issue's own scenario: a workspace with a valid custom rule and a syntactically broken one in the same --yara-rules-dir. The good rule fires, but the run reports a clean scan regardless.

What this changes, and a design choice I want to flag

  • The skip count is recorded on the same module-level cache the compiled rules already live on (_rules_skipped_count, read back via the new rules_skipped_count()), and folded into a PARTIAL ledger event in node(), using the existing READ_ERROR reason and LedgerRecordType.SYSTEM (it isn't scoped to a scanned skill file, so I used a synthetic "yara_rules/" path — ledger paths must be relative POSIX, and the real rules directory is absolute).
  • That event flows through the existing degraded/completed decision in node() unchanged, so this is additive to the existing status machinery rather than a new mechanism.
  • Deliberately did not change _load_rules's return signature. My first pass returned (compiled_rules, skipped_count) as a tuple, which is the more obvious API, but 15 tests in test_static_yara.py do monkeypatch.setattr(static_yara, "_load_rules", lambda _extra_dir: rules), returning a bare yara.Rules object — all of them would have silently broken by unpacking a yara.Rules as a 2-tuple. I chose the module-global read-back instead specifically to avoid that blast radius for an internal detail those tests don't exercise. Happy to go the tuple route instead if you'd rather have the cleaner API and take the test-file diff — just say so.
  • Did not use SYNTAX_ERROR (already reserved for "Python source could not be parsed" per REASON_MESSAGES) or invent a new LedgerReason for this; READ_ERROR's existing message ("File content could not be read") is generic enough to cover both the decode and compile failure cases the count already sums together.

Testing

.venv/bin/python -m pytest tests/nodes/analyzers/test_static_yara.py -q
# 87 passed

.venv/bin/python -m pytest -q
# 4971 passed, 14 skipped, 38 deselected, 4 xfailed

.venv/bin/python -m ruff check src/skillspector/nodes/analyzers/static_yara.py tests/nodes/analyzers/test_static_yara.py
# All checks passed!

.venv/bin/python -m ruff format --check src/skillspector/nodes/analyzers/static_yara.py tests/nodes/analyzers/test_static_yara.py
# 2 files already formatted

New test builds a valid rule and a syntactically broken one (missing closing brace, a real YARA syntax error, matching the issue's own repro rather than a decode failure) in the same --yara-rules-dir, asserts the valid rule still fires, the analyzer status is not "completed", and the ledger records the drop with observed_artifacts=1.

Negative control, reverting only static_yara.py and keeping the test:

AssertionError: a dropped custom rule must not report a clean scan
assert 'completed' != 'completed'

Restoring the fix, all 87 tests in the file pass again.

I did not reproduce this on Windows (the issue notes the same result on Windows 10 and Ubuntu/WSL2) — tested on Linux only, CPython 3.12.14, yara-python==4.5.4 (same version the issue reports).

…pleted

A rule file passed through --yara-rules-dir that YARA cannot compile, or
that SkillSpector cannot decode as UTF-8/base64, is dropped whole with no
signal above debug-level logging. _load_rules already counted these
(materialize_skipped + compile_skipped) but only logged the total; node()
never saw it, so every scanned component could still report COMPLETED and
the recommendation stayed SAFE, because the rule that would have flagged
something simply never ran. --fail-on-incomplete correctly has nothing to
key off, so it exits 0.

Kept _load_rules's existing single-value signature: every current
monkeypatch.setattr(static_yara, "_load_rules", ...) test double in the
suite returns a bare yara.Rules object, and changing the return shape to a
tuple would have broken all 15 of them for an internal detail those tests
don't exercise. The skip count is instead recorded on the same module-level
cache the compiled rules already live on, read back via the new
rules_skipped_count(), and folded into a PARTIAL ledger event scoped to the
rule set (not a scanned skill file, hence the synthetic "yara_rules/" path
and LedgerRecordType.SYSTEM) using the existing READ_ERROR reason. That
event flows through node()'s existing degraded/completed decision
unchanged, so --fail-on-incomplete now has something real to key off.

Test builds a valid rule and a syntactically broken one in the same
--yara-rules-dir (a real YARA syntax error, not a decode failure, to match
the issue's own repro), asserts the valid rule still fires, the analyzer
status is not "completed", and the ledger records the drop. Negative
control: reverting only the source fails with status == "completed" — the
exact false-SAFE the issue reports.

Fixes NVIDIA#554

Signed-off-by: Souptik Chakraborty <62941615+Souptik96@users.noreply.github.com>

@rng1995 rng1995 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SkillSpector Review]

Reviewed current head 4e753fe71cae2a3ecfe7df258c760115a1ed3f6c, including the complete two-file diff, surrounding rule-cache and analyzer-status logic, tests, existing discussion, and exact-head checks. The mixed valid/invalid-rule case is now surfaced in the ledger, and all five hosted checks pass.

Changes are requested because the skipped-rule count is stored in a module global and read separately after _load_rules returns. Concurrent scans with different rule directories can interleave those operations, so one scan can consume another rule set's count and still report completed after its own rule was dropped. Bind the skip metadata atomically to the returned/cached compiled rule set (or protect the load-and-read operation with appropriate synchronization) and add a deterministic concurrency regression.

return _rule_limit_response(exc.reason, dict(exc.metrics))
finally:
_RULE_LOAD_DEADLINE.reset(deadline_token)
rules_skipped = rules_skipped_count()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Bind skipped-rule metadata to the returned rules

rules and rules_skipped are obtained from two separately mutable module globals. Two concurrent MCP/graph scans can interleave after _load_rules(A) returns: scan B can load rules B and overwrite _rules_skipped_count before scan A calls rules_skipped_count(). Scan A then runs rules A with B's count, potentially reporting completed even though an A rule was dropped. Return/cache the compiled rules and their skip metadata as one value, or lock the load-and-read transaction, and add a regression that forces this interleaving.

@yashrajp22 yashrajp22 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The review of head 4e753fe71cae2a3ecfe7df258c760115a1ed3f6c is complete. Two additional fixes are needed: the synthetic rule-load event can collide with a real file, and rejected rules still lack the default-level diagnostics requested in #554. The existing skip-count concurrency finding also remains reproducible; I have not duplicated that comment.

The ordinary mixed valid/invalid-rule case now correctly produces a nonfatal partial report, strict CLI exit 1, and safe_to_install=false through programmatic MCP.

Validation used fresh wheels and pinned source for base c13f70ebf14905912c616a58c9a8cb8112ef94a4 and this head. All 12 complete sample directories ran in all four combinations (48 scans), with matching source/wheel reports. The 98 selected tests passed in each HEAD mode. Focused checks covered malformed syntax/encoding/BOM, cache transitions, concurrency, ledger identity, CLI/MCP, suppression and all report formats, resource/failure precedence, and recursive/transitive aggregation. Greptile's cache-metadata observation was independently reproduced and grouped with the existing shared-metadata finding.

Scope: Linux and offline checks; transitive remote targets were mapped to local fixtures. Nine sample reports remain partial for unrelated reference/obfuscation limitations, so this is not an all-rule accuracy or live-provider result. The PR contribution is based on 2e9ae8d1cfa6e339f7035f876d3ac2e1c6ce24e6; unrelated AS3 differences from newer main were kept separate.

# set itself. Ledger paths must be relative POSIX paths, and
# the real rules directory (builtin or --yara-rules-dir) is
# absolute, so it cannot be used here.
path="yara_rules/",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we give rule-load events a work ID that cannot overlap with component work? With a valid file named yara_rules and one rejected custom rule, the ledger normalizes this path to yara_rules, so both events have the same static_yara work ID. I reproduced fatal unaccounted_work, execution_successful=false, and CLI exit 2. This should remain a nonfatal partial scan (strict exit 1). Changing only the synthetic filename would still allow another valid filename to collide.

sources, materialize_skipped = _build_namespace_map(rule_files, raw_cache=raw_cache)
compiled, compile_skipped = _compile_rules(sources)
skipped = materialize_skipped + compile_skipped
_rules_skipped_count = skipped

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we also report each rejected rule at the default WARNING level, including its filename and a bounded decode/compile reason, as #554 requests? A malformed acme.yar, a BOM rule, and a non-UTF-8 .yar still produce no warning. This count now makes ordinary scans partial, but the public event only says File content could not be read for yara_rules, so the user cannot identify or repair the dropped detector. The rejection handlers remain at DEBUG.

…iles

Addresses the three review findings on NVIDIA#557. All three share one shape: the
dropped-rule total was reported through a channel not tied to the scan that
produced it.

1. Skip count raced across concurrent scans (rng1995, P1)

`node()` called `_load_rules()` and then read `rules_skipped_count()` as a
separate step. Two concurrent MCP/graph scans can interleave between those:
scan B loads its own rule set and overwrites `_rules_skipped_count` before
scan A reads it, so A runs rules A while reporting B's total. If B skipped
nothing, A reports `completed` even though one of A's own rules was dropped --
the false-clean result NVIDIA#554 exists to prevent.

Adds `load_rules_with_skips()`, which returns the rules and their own skip
count from one transaction guarded by a reentrant `_RULES_LOCK`, and switches
`node()` to it. `_load_rules()` keeps its single-value signature, and
`load_rules_with_skips` calls it through the module global, so every existing
`monkeypatch.setattr(static_yara, "_load_rules", ...)` double still applies.
`rules_skipped_count()` is retained for single-threaded callers and now reads
under the lock. The three cache globals are documented as one logical value
that must only be written or read as a set.

The lock serializes rule compilation across concurrent scans. That is a
deliberate trade: compilation is cached and already deadline-bounded, and a
scanner reporting a false clean is worse than one loading rules serially.

2. Rule-load event collided with a component of the same name (yashrajp22)

`ledger_event` derives the work identity as
`analyzer_id or f"{record_type}:{phase}"`, and the synthetic `yara_rules/`
scope normalizes to `yara_rules`. Passing `analyzer_id=ANALYZER_ID` therefore
produced the same work ID as the planned work item for a scanned component
literally named `yara_rules`: both planned targets resolved to two matching
events, and reconciliation raised a fatal `unaccounted_work` with
`execution_successful=false` and CLI exit 2, instead of the nonfatal partial
scan this event is meant to record.

Omits `analyzer_id` on that one event so the identity falls back to
`system:static`, which is disjoint from every analyzer work item by
construction. As the review noted, renaming the synthetic path alone would
only move the collision to the next unlucky filename.

3. Rejected rules were invisible at default log level (yashrajp22, NVIDIA#554)

Both rejection handlers logged at DEBUG, so a malformed `acme.yar`, a BOM
rule, or a non-UTF-8 `.yar` produced no default-level warning, and the public
ledger event is scoped to the rule set rather than the file. The operator
could see that a detector was dropped but not which one to repair.

Both handlers now log at WARNING, naming the file and a bounded reason.
`_build_namespace_map` optionally fills a `{namespace: filename}` map -- passed
in rather than returned, to keep its two-value signature -- so the compile path
can name `acme.yar` instead of the extension-stripped namespace `acme`.
`_bounded_rejection_reason` collapses newlines and caps the echoed text at 200
characters, because rule sources are attacker-influenced when
`--yara-rules-dir` points at untrusted content and YARA errors can quote the
offending source line.

Tests

New `TestRuleSkipAccounting` (9 tests): a deterministic pairing test, a
serialization test that asserts the lock is genuinely held for the whole
load-and-read transaction rather than racing and hoping, a contended
two-thread test over 50 observations, the `yara_rules` work-ID collision case
asserting both event and planned-work IDs stay distinct, three parametrized
rejection-diagnostic cases (malformed, BOM, non-UTF-8), and two bounding
tests. The contended test surfaces worker-thread exceptions and asserts an
observation count, so it cannot pass vacuously when the scans never ran.

The autouse cache fixture now also resets `_rules_skipped_count`, which is
part of that cache and would otherwise leak between tests.

Verification

- Negative control: all 9 new tests fail with the source change reverted and
  the tests kept; 9/9 pass with it.
- `tests/nodes/analyzers/test_static_yara.py`: 96 passed.
- Full suite: 18 pre-existing failures, byte-identical to the same run on
  unmodified `4e753fe` (build_context, compare_scan_accuracy,
  create_github_release, input_handler, json_container_ownership,
  security_end_to_end -- all environmental, none in the touched files).
- `ruff check`, `ruff format --check`, and `mypy` clean on both files.
- Windows / Python 3.13 only; the pre-existing failures above are consistent
  with that environment rather than with this change.

Signed-off-by: Souptik Chakraborty <62941615+Souptik96@users.noreply.github.com>
@Souptik96

Copy link
Copy Markdown
Author

Thanks both — the reviews were specific enough to fix directly, and @rng1995's point about the two globals was the one I should have caught myself. 6e07493 addresses all three findings.

All three turned out to be the same shape: the dropped-rule total was reported through a channel that wasn't tied to the scan that produced it — a module global read after the fact, a ledger work ID shared with component work, and a DEBUG log nobody sees at default verbosity.


① Skip count bound to its rules — @rng1995 [P1]

rules and rules_skipped are obtained from two separately mutable module globals … Return/cache the compiled rules and their skip metadata as one value, or lock the load-and-read transaction, and add a regression that forces this interleaving.

Done both. New load_rules_with_skips() returns the rules and their own count from one transaction guarded by a reentrant _RULES_LOCK; node() now calls it instead of _load_rules() followed by a separate rules_skipped_count().

_load_rules() keeps its single-value signature, and load_rules_with_skips calls it through the module global, so every existing monkeypatch.setattr(static_yara, "_load_rules", ...) double still applies — that compatibility was the reason the count was a global in the first place, and it survives. rules_skipped_count() stays for single-threaded callers and now reads under the lock. The three cache globals are documented as one logical value that must only be written or read as a set.

Confirmed the interleaving before fixing it — scan A drops one rule, scan B loads a clean set, A then reads B's count:

OLD separate-read: A dropped 1, reads 0  -> misreports clean: True
NEW atomic:        A got 1 (expect 1), B got 0 (expect 0)
threaded 25x2 under contention: mismatches = 0

For the regression you asked for, I did not want a test that passes on timing luck, so there are two. test_load_and_read_is_serialized_against_other_scans proves the lock is genuinely held for the whole transaction: mid-transaction it starts another thread and asserts that thread cannot acquire _RULES_LOCK at all. test_concurrent_scans_never_report_another_rule_sets_count then runs two real scans over 50 observations and asserts each sees only its own total.

One trade to flag explicitly: the lock serializes rule compilation across concurrent scans. I judged that acceptable because compilation is cached and already deadline-bounded, and a scanner reporting a false clean is worse than one loading rules serially. If you would rather not serialize compilation, the alternative is caching (rules, skipped) as a single immutable value and having callers hold a reference to it — happy to switch if you prefer that shape.


② Rule-load work ID can no longer collide — @yashrajp22

Could we give rule-load events a work ID that cannot overlap with component work? … Changing only the synthetic filename would still allow another valid filename to collide.

Agreed, and that last sentence is why I did not just rename the path. ledger_event derives the identity as analyzer_id or f"{record_type}:{phase}", so passing analyzer_id=ANALYZER_ID made this event static_yara + yara_rules — identical to the planned work item for a component of that name. The event now omits analyzer_id, so the identity falls back to system:static, which is disjoint from every analyzer work item by construction. No filename can collide, not just not yara_rules.

Reproduced your exact case first (a component named yara_rules plus one rejected rule):

before:  work-9f4138c79afbaae... path='yara_rules' type=work_item
         work-9f4138c79afbaae... path='yara_rules' type=system     <- identical
         DUPLICATE work_ids: 1
after:   work-9f4138c79afbaae... path='yara_rules' type=work_item
         work-09561a184b9163d... path='yara_rules' type=system
         DUPLICATE work_ids: 0

The regression asserts both the event IDs and the advertised planned_work IDs stay distinct, since reconciliation requires exactly one event per planned target — and that the scan is still partial rather than clean.


③ Rejected rules named at default level — @yashrajp22, #554

Could we also report each rejected rule at the default WARNING level, including its filename and a bounded decode/compile reason, as #554 requests? … The rejection handlers remain at DEBUG.

Both handlers now log at WARNING with the filename and a bounded reason. Your three cases:

WARNING static_yara: rejected rule file bad_utf8.yar (could not decode): 'utf-8' codec can't decode byte 0xff in position...
WARNING static_yara: rejected rule file acme.yar (could not compile): line 0: syntax error, unexpected end of file...
WARNING static_yara: rejected rule file bom.yar (could not compile): line 1: non-ascii character

This needed one change beyond the log level, which is worth calling out: the compile path only had the namespace, and _rule_namespace() strips the extension, so it would have logged acme rather than acme.yar. _build_namespace_map now optionally fills a {namespace: filename} map — passed in rather than returned, to keep its two-value signature that existing callers and tests unpack directly.

_bounded_rejection_reason collapses newlines and caps the echoed text at 200 characters, because rule sources are attacker-influenced when --yara-rules-dir points at untrusted content and YARA errors can quote the offending source line.


Verification

  • Negative control: all 9 new tests fail with the source change reverted and the tests kept; 9/9 pass with it.
  • tests/nodes/analyzers/test_static_yara.py: 96 passed.
  • Full suite: 4941 passed, 19 pre-existing failures — I ran the same suite on unmodified 4e753fe and the failure set is identical (build_context, compare_scan_accuracy, create_github_release, input_handler, json_container_ownership, security_end_to_end; none in the touched files). test_security_end_to_end.py gives the same 3 failed / 88 passed either way.
  • ruff check, ruff format --check, and mypy clean on both files.
  • Windows / Python 3.13 only. The pre-existing failures above are consistent with that environment rather than with this change, but I have not verified on Linux or macOS locally — the hosted matrix covers that.

One thing I found in my own tests rather than let it sit: the contended-threads test initially passed vacuously, because an exception inside a worker thread does not fail a pytest test. It now captures worker exceptions and asserts an observation count of 50, so it cannot pass when the scans never ran. Also extended the autouse cache fixture to reset _rules_skipped_count, which is part of that cache and was leaking between tests.

Disclosure: written with AI assistance under my direction. I reproduced each finding before fixing it, ran the negative control and full-suite comparison myself, and reviewed this comment before posting.

@rng1995 rng1995 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SkillSpector Review]

Re-reviewed current head 6e07493c9956dcf2ad7b2f032f58b125cc978fa9 against all three prior threads, the complete rule-cache/ledger/logging diff, concurrency tests, surrounding no-rules paths, and exact-head checks.

The lock now makes a successful load-and-count transaction atomic, and the work-ID collision plus default-level rejected-file reporting are addressed. One cache-integrity path remains. _load_rules() sets _rules_skipped_count and returns without replacing or clearing _compiled_rules / _rules_hash when no rule files exist or compilation yields no rules. A later request for the previously cached hash then returns those cached rules paired with the intervening load's count. For example, load A with one valid and one rejected rule, load an empty/all-rejected set B, then load A again: the A cache hit can report B's count (including zero), recreating a false-complete YARA scan. Keep rules, hash, and skip count as one immutable cache entry or invalidate the cached rules/hash on every non-populating path; add this A→B→A sequence as a regression.

All six exact-head checks pass, but this remaining completeness-accounting defect and active change requests block merging.

Priority: P0 — incorrect rule-drop accounting can make an incomplete malware scan look complete.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Custom YARA rules that fail to compile are dropped silently: static_yara still reports completed and SAFE

3 participants