Skip to content

Render matched strings on hunt results - #267

Merged
kyle-buchmiller merged 16 commits into
developfrom
DN-8378-yara-matched-strings
Sep 10, 2026
Merged

kyle-buchmiller merged 16 commits into
developfrom
DN-8378-yara-matched-strings

Conversation

@kyle-buchmiller

Copy link
Copy Markdown
Contributor

TL;DR

  • polyswarm live result <id> and polyswarm historical result <id> now show the yara strings behind a match, between Rule: / Tags: and Download Url:.
  • Three states render differently and are never collapsed.
  • JSON output needs no change — it already dumps the resource's .json.
  • specs/03-formatters.md records the rendering rule; tests drive the formatter directly.

Requires

This PR must not merge before that one is on the SDK's develop: CI installs the SDK from $CI_COMMIT_BRANCH.zip || develop.zip, so once the branches diverge the fallback would be an SDK without the attribute.

What it looks like

Rule: dos_stub_message
Tags: {pe,stub}
Matched Strings:
  $msg @ 0x4e (38 bytes): This program cannot be run in DOS mode
  $mz @ 0x0 (512 bytes, truncated): 4D 5A 90 00 03 00 00 00 ...

Why absent renders nothing but empty does not

The two look like the same case and are not:

  • absent → no line at all. It overwhelmingly means "you are looking at a list route", which can never carry strings, so an explanation there would be a permanent false alarm on every row — and live feed renders through this same method, with nothing on the resource to tell the routes apart.
  • empty → Matched Strings: none — the rule matched without byte evidence (a structural or negative match, or private strings). This only ever reaches a detail route, where it is a real answer to "why did this hit".

An earlier revision named the command that would carry the evidence. It was dropped: the same method renders both routes, so on a detail fetch the hint told you to re-run the command you just ran, and fixing that properly would mean a new parameter on every BaseOutput implementation.

truncated is deliberately not rendered as a byte count — the stored length is capped server-side, so it means "there was more than this".

Notes for review

  • The four .click cassette expectations net out unchanged against develop — the block adds no output to any path they exercise. The diff is pure addition.
  • Tests are the pure-rendering tier (specs/04-testing.md Style 3): the cassettes predate the field, so every result they render takes the silent branch. They pin that no stray line appears, and nothing more.
  • No pyproject.toml version bump; the SDK pin is unchanged, since the new attribute is additive and read with .get().

Verification

132 tests pass. Verified end to end against a locally running stack with matching server and SDK branches: populated strings on a live-hunt result, and the empty state across 25 historical results from a structural rule.

Shows the yara strings behind a hit, between Tags and Download Url.

None is deliberately not rendered as silence. The feature exists to answer
"why did this rule hit", and an absent line answers nothing -- it is
indistinguishable from a rule that matched with nothing to show. Each of the
three states gets a line saying which it is.

The None line names its causes rather than the command that would carry the
evidence. A `try polyswarm live result <id>` hint is tempting, since the
dominant None case is the list route omitting the strings rather than
fetching a blob per row. But this method renders both routes -- live feed
loops over it -- and nothing on the resource tells them apart, so on a
detail fetch the hint would say to re-run the command you just ran. Getting
that right means threading a route flag into a new parameter on every
BaseOutput implementation, which this does not earn.

The four text cassette expectations are regenerated through click_vcr's own
record path; the recorded HTTP is untouched. They predate the field, so they
pin only the None line.
Style 3 -- the formatter driven directly with constructed resources, since
the question is which line a field value produces, not command behaviour.
The cassettes above reach only the None line, so they are not a substitute.

Asserts the empty and absent lines stay different from each other, that
truncation is marked only where it applies, and that the block sits between
Tags and Download Url rather than being appended last. The absent-line
assertions match substrings rather than the whole sentence, so rewording the
prose does not turn this into a spelling check.
The spec asks for a resource's rendering rules once they are non-obvious or
contested, and this one is both: it forbids rendering the absent state as
silence, and it explains why the absent line names causes instead of a
command to run, so the hint is not helpfully reintroduced later.
The absent state used to render a line explaining why the evidence was
missing. On a list route that is a permanent false alarm: those endpoints
never look for the strings, so every row of every page carried an
explanation for a lookup that was never attempted.

Absent now renders nothing at all. The case worth explaining survives
untouched -- an empty list still says the rule matched with no byte
evidence, and that one only ever reaches a detail route, so its line is
never noise.

This method renders both routes (the feed loops over it) and nothing on the
resource distinguishes them, so the choice is per-state rather than
per-route. Route awareness would mean threading a flag from the command
layer into a new parameter on every BaseOutput implementation, for a line
that is unwanted on one route and near-vestigial on the other: once the
analyzer always reports strings, absent on a detail route means only that
the result predates the feature.

The absent-state test inverts rather than disappears -- it now asserts no
line is emitted -- so this cannot be quietly undone. The four text cassette
expectations are regenerated through click_vcr's own record path; the
recorded HTTP is untouched.
Comment text only -- the parsed AST is identical before and after.

The largest cut of the pass, because most of what was there argued against a
design that is no longer in the code: a hint naming the command that would
carry the evidence, which was tried, found to be circular on the detail route,
and dropped. The reasoning for the choice that was actually made -- absent
renders nothing, empty keeps its line -- is what remains.
@claude

claude Bot commented Aug 25, 2026

Copy link
Copy Markdown

Review

Base (develop), no version bump, ## Requires link, Style-3 test tier, spec updated in the same PR — gitflow and process are all correct. One blocking correctness issue.

1. result.matched_strings is a bare attribute read, but the SDK pin still admits SDKs without it (blocking)

src/polyswarm/formatters/text.py:266 and :298 read result.matched_strings directly. pyproject.toml pins polyswarm_api>=4.3.0,<5.0.0 and is unchanged by this PR; the SDK PR that adds the attribute (polyswarm/polyswarm-api#322) is still open, so no released SDK in the allowed range has it. Against 4.3.x this is AttributeError on the first result rendered — and the blast radius is not the new feature, it is every text-mode hunt path: live result, live feed, live delete, historical result, historical results, historical delete (all six funnel through these two methods, per client/live.py:48,57,68 and client/historical.py:104,113,124).

The PR body-s justification does not hold:

No pyproject.toml version bump; the SDK pin is unchanged, since the new attribute is additive and read with .get().

.get() is what the SDK does when parsing content (#322, resources.py) — that is what makes None vs [] work on a new SDK. It says nothing about what happens on an old one. This code does plain attribute access.

specs/05-sdk-contract.md §Version pin: "Floor it at the lowest SDK version that exposes every method/behaviour the CLI relies on." And the sibling section in the very spec this PR extends, 03-formatters.md §Known-good, sets the precedent for exactly this case:

Both attributes are read with getattr(..., None) so a CLI on an older SDK (missing either field) never raises AttributeError

Two ways out, and the spec effectively picks one:

  • getattr(result, "matched_strings", None) — matches the known-good precedent, degrades to the silent None branch (which the new spec section already defines as "renders nothing"), and makes "the pin is unchanged" actually true.
  • Bump the floor — but 05-sdk-contract.md requires the target version to be on PyPI and declared by the SDK-s develop, neither of which is possible until #322 lands and ships. So this cannot be done in this PR.

Whichever you take, the new §Matched strings spec section should say so. It is currently silent on the SDK-version question while the section directly above it documents its own in detail — a reader cannot tell whether the omission is a decision or an oversight.

(CI will not catch this: the SDK branch name matches this one exactly, so $CI_COMMIT_BRANCH.zip resolves and the feature-branch SDK gets installed. Green CI here is the paired-PR mechanism working as designed, not evidence the pin is right.)

2. Pre-existing spec drift you will land on while fixing the above

specs/05-sdk-contract.md:77 — "Current floor — polyswarm_api>=4.2.0" — vs pyproject.toml:25 polyswarm_api>=4.3.0,<5.0.0. Not introduced by this PR (03-formatters.md:145 repeats the stale 4.2.0), but it is the section anyone touching the pin reads first. Worth correcting while you are in here.

3. Minor — inconsistent entry-key access

text.py:56-63 subscripts identifier/offset/length/data but reads truncated via .get(). The SDK contract documents all five keys as present on every entry, so the subscripts are defensible — but then .get("truncated") is defending against a shape the contract says cannot occur, while a genuinely partial entry still KeyErrors two lines earlier. Pick one.

Nothing else: placement, the three-state rendering, the truncated-is-not-a-byte-count decision, and the None-renders-silence rationale are all sound and well documented, and the test file pins each of them.

`result.matched_strings` was a bare attribute read while the dependency pin
still admits SDKs that predate the attribute, so on any released SDK in range
it raises AttributeError -- and not only on the new output. All six text-mode
hunt commands funnel through these two formatter methods, so `live feed`,
`live result`, `live results-delete` and the three historical equivalents
would all break.

Now read with getattr, matching the defence the known-good attributes in this
same file have used since they landed, and for the same reason. A missing
attribute lands on the silent None branch, which is also the honest reading:
an SDK that cannot see the field does not know.

CI could not have caught this. The paired SDK branch shares this branch's
name, so the archive install resolves to it and the feature-branch SDK gets
installed -- green here means the paired-PR mechanism works, not that the pin
is right. Nor could the existing tests: they build resources from the
installed SDK, so a bare read passes all of them. The new test deletes the
attribute to stand in for an older SDK and is the only guard; it fails with
AttributeError against the previous code.

Also subscripts `truncated` like the other four keys. Mixing a subscript for
`length` with `.get()` for `truncated` defended against a shape the contract
says cannot occur while a genuinely partial entry still raised two lines
earlier.

Unrelated and pre-existing: the specs still named `>=4.2.0` as the dependency
floor, which moved to 4.3.0 in cdb7926 without a spec change. Corrected, with
the provenance recorded -- the per-behaviour writeup below it is still the
4.2.0 one, accurate about why 4.2.0 was needed but no longer the binding
constraint.
@claude

claude Bot commented Aug 25, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md, specs/03-formatters.md, 04-testing.md, 05-sdk-contract.md. The rendering itself is right: three states stay distinguishable, the getattr read matches the known-good precedent, placement is between Tags: and Download Url: on both methods, JSONOutput genuinely needs no change (json.py:91-95 dumps result.json), and the delete routes are safe — live_feed_delete parses through LiveHuntResultList, which per the SDK's contract omits the key, so [] really can't reach a non-detail route. Base is develop, no version bump, SDK PR linked under ## Requires. Four things.

1. tests/hunt_matched_strings_test.py:140 — the older-SDK guard is the one test that fails on an older SDK.

del result.matched_strings raises AttributeError when the attribute was never set — i.e. exactly the pre-#322 SDK the test exists to simulate. On the pinned floor (4.3.0) every other test in the file passes (missing attribute → getattr default → silent branch) and this one errors, which inverts what it's asserting. result.__dict__.pop('matched_strings', None) gets you the same starting state either way.

2. specs/05-sdk-contract.md:75 now contradicts the heading below it.

The §Version pin paragraph still reads "For the current floor both were read from `origin/develop`: `version = "4.2.0"` and `version = '4.2.0'`, no suffix", while §Current floor (line 77) now says 4.3.0. The new note scopes its disclaimer to "the per-behaviour writeup below" — this sentence is above the heading, so it isn't covered. Reword to "For the 4.2.0 floor…".

3. The pin has no recorded follow-up.

Not bumping now is correct — §Version pin's precondition is that the version is on PyPI, and the paired SDK isn't released. But the invariant is "Floor it at the lowest SDK version exposing every method/behaviour the CLI relies on", and on a 4.3.0 install polyswarm live result renders no strings and says nothing about why — the same silent-degradation class that §Current floor cites as the reason the 4.2.0 floor is hard rather than preferred. Add a line to §Current floor naming the release that must become the new floor once it publishes, otherwise nothing carries that decision forward.

4. DN-8378 will land in public history if this merges as a merge commit.

AGENTS.md §Commit + PR hygiene keeps ticket IDs out of commit messages, and the default merge subject is Merge pull request #267 from polyswarm/DN-8378-yara-matched-strings. Commits and PR title/body are clean; squash or edit the merge subject.

Minor, no action needed unless you disagree: the PR body says the attribute is "read with .get()" — it's getattr; and both inline comments say "same defence as the known-good reads below", which are above at text.py:105-108.

Renders a final line inside the block when the server reports a withheld count:

    … 19 more not shown (result size limit)

Yellow rather than white -- it is the one line here reporting something the
platform did not send. Omitted entirely when nothing was withheld.

Not cosmetic. Without it a truncated list reads as the whole truth and a user
concludes their rule hit twice when it hit twenty-one times, which is the same
wrong-inference class the three-state contract already exists to prevent, one
level down.

Read with getattr for the same reason as `matched_strings` itself: the
dependency floor admits SDKs predating the field, and a bare attribute read
would AttributeError on every text-mode hunt command rather than just the new
output. A test deletes the attribute to stand in for an older SDK.

142 tests pass.
@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown

Review

Mostly clean against AGENTS.md / specs/: base is develop, no pyproject.toml version bump, ## Requires links the SDK PR, the getattr defence matches the known-good precedent, and Style 3 plus the existing cli_test.py cassettes satisfy specs/04-testing.md §Style 3's "still needs at least one CliRunner test proving the command reaches the formatter". Three things.

1. [] plus a non-zero dropped count renders a false statement (correctness)

src/polyswarm/formatters/text.py:53-56 returns early on the empty branch, before the dropped check:

if not strings:
    return [self._white('Matched Strings: none — the rule matched without byte evidence …')]

So matched_strings=[] with matched_strings_dropped=19 prints "the rule matched without byte evidence" and silently discards the 19 — the exact wrong inference the dropped line was added to prevent, made worse by the sentence asserting the opposite. The PR argues the server always keeps the first string, but that assumption is trusted here while the same commit adds getattr defence against SDK/server shapes that don't match expectations elsewhere. Either render the dropped line in that branch too, or state the [] => dropped == 0 invariant explicitly in specs/03-formatters.md §The dropped-count line, which today only says "Omitted entirely when the count is zero or None".

Same point as a test gap: test_dropped_line_does_not_fabricate_a_strings_block pins None + dropped; there is no case for [] + dropped.

2. The SDK version carrying the attributes isn't recorded (spec)

specs/03-formatters.md:184-187 says the attribute "ships in the paired SDK release" without naming it, and the specs/05-sdk-contract.md §Current floor edit doesn't mention matched_strings at all. Compare the known-good precedent at the end of that same section, which names 4.1.0 and says explicitly that it is covered by the floor rather than setting it. Not bumping the floor is the right call here (graceful degradation, and the SDK release isn't on PyPI yet — §Version pin's two preconditions), but with no version recorded nothing will prompt an evidence-based decision later. Add it once polyswarm/polyswarm-api#322 releases.

3. Ticket ID in the branch name (gitflow, minor)

Commit messages and the PR body are clean, but the branch is DN-8378-yara-matched-strings, which lands in the default merge-commit subject on develop — the leak AGENTS.md §"Commit + PR hygiene" guards against on a public repo. Squash-merge with a clean title, or rename the branch.

…d count

The empty branch returned early, before the dropped check. So an empty list
arriving with a non-zero count printed "the rule matched without byte evidence"
and silently dropped the number -- a confident false statement about the rule,
and the precise wrong inference the count was added to prevent.

That combination should be unreachable: the analyzer keeps a match's first
string, so [] implies dropped == 0. But the renderer was trusting an invariant
owned by another repo while making a positive claim, in the same function that
defends with getattr against shapes it does not control. Now it reports what is
certain -- "none shown (N withheld, result size limit)" -- and asserts nothing
about why the rule fired. The normal empty case is unchanged.

Also records in 05-sdk-contract.md that the floor does not yet cover
matched_strings / matched_strings_dropped. Not bumping it is right -- the SDK
carrying them is not on PyPI, so neither precondition in the version-pin section
is met -- but with no version written down nothing would later prompt the
decision of whether the graceful degradation is still wanted.

146 tests pass.
@claude

claude Bot commented Aug 29, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md, specs/03-formatters.md, specs/04-testing.md, specs/05-sdk-contract.md.

Gitflow is clean: base is develop, no pyproject.toml bump, ## Requires links the SDK PR, and the specs for both touched areas move in the same PR. Findings below, most-significant first.


1. The two "older SDK" tests error on exactly the SDKs they model.

tests/hunt_matched_strings_test.py — test_an_sdk_without_the_attribute_does_not_raise and test_older_sdk_without_the_dropped_attribute_does_not_raise both del result.matched_strings[_dropped]. polyswarm-api#322 sets these as plain instance attributes (self.matched_strings = content.get(...)), so on any SDK predating it the del raises AttributeError and the test errors rather than passing — in the one configuration whose behaviour it claims to pin.

That configuration is inside the declared pin: polyswarm_api>=4.3.0,<5.0.0, and 4.3.0 is released without the attribute (#264). So pip install .[tests] && pytest against the PyPI floor errors on these two and fails ~10 more (_matched_lines returns [], and header, first, second = ... raises ValueError). CI is fine while the branch names match — .gitlab-ci.yml resolves $CI_COMMIT_BRANCH.zip and the SDK branch is identically named — but this is also what breaks unrelated CLI branches if #267 lands on develop before #322 does. The PR body's merge-ordering note covers the ordering; it doesn't cover the floor.

Cheapest fix that keeps the guard honest: module-level pytest.mark.skipif(not hasattr(resources.LiveHuntResult(...), 'matched_strings')), or build the older-SDK stand-in without del.

2. Per-entry keys are hard-subscripted while everything above them is defended.

text.py:71-77 reads string['length'], string['truncated'], string['identifier'], string['offset'], string['data'] by subscript, one line below a getattr(..., None) justified at length in the spec. These entries are server-shaped and pass through the SDK untouched — #322's own rationale is "Read with .get(), never a subscript: both keys are additive, so a server predating them omits them and a subscript would raise on every result." The CLI takes the opposite stance one level down, where the same argument applies: an entry missing or renaming a key KeyErrors the render for every hunt result, in text mode, on live result / live feed / live results-delete and the three historical equivalents.

The inline comment says this is deliberate ("fail loudly on a partial entry rather than render half-right"), which is a defensible call — but specs/03-formatters.md is authoritative for this area and doesn't record it, so the next reader sees only the inconsistency. Either soften to .get() with a placeholder, or state the fail-loud decision in the §Matched strings section next to the getattr rule it contradicts. (Aside: "Subscripted like the other four" doesn't parse — there are five subscripts in that one f-string.)

3. data is sample-derived bytes echoed straight to a terminal.

text.py:77 interpolates string['data'] unescaped. The escaping guarantee lives entirely in the SDK/server spec ("text as ASCII with escapes"); nothing in this repo pins it and no test covers a control-character/CR/ANSI-escape payload. For a malware-analysis CLI that's the one field an attacker controls end to end. Worth either a test with a hostile data value or an explicit note in §Matched strings that the escaping is upstream's contract.

4. Minor — first non-ASCII in text output.

— (line 63) and … (line 79) are the first non-ASCII characters TextOutput emits. --output-file is explicitly encoding='utf8' so that path is safe; stdout under a C/POSIX locale goes through click's replacement wrapper and degrades to ?. Low impact, and trivially avoided with -- / ... if you'd rather not carry the assumption.

5. Minor — the deferred floor decision has no home.

specs/05-sdk-contract.md adds "record the version here once it releases … Without a version written down nothing prompts that decision." Nothing does prompt it — specs/99-open-questions.md is the documented place for follow-ups and isn't touched. One line there closes the loop, and it's also the natural place to note that raising the floor is the trigger to drop the getattr defence and the skip guard from finding 1.

Three things, the first two of which were broken rather than merely untidy.

The two "older SDK" tests errored on exactly the SDKs they modelled. Both did
`del result.matched_strings[_dropped]`, and the attribute is set in __init__ --
so on an SDK that never set it, `del` raises AttributeError and the test errors
instead of passing. That configuration is inside the declared pin: 4.3.0 is
released without these fields, so `pip install .[tests] && pytest` against the
floor errored on those two and failed a further ten. Now popped from __dict__,
which works either way, with a module-level skip so a run against a floor SDK
skips honestly rather than failing wholesale. CI was green throughout only
because the paired SDK branch shares this branch's name.

`data` is now sanitised before rendering. It is the one field in a hunt result
derived from the sample, so it is attacker-controlled end to end, and it was
interpolated into terminal output unescaped. yara escapes non-printables and the
analyzer preserves that rendering, so the substitution is a no-op on anything
valid -- it exists because that guarantee lives in another repo, and a raw CSI
sequence reaching a terminal would repaint or clear an analyst's screen. Trusting
an upstream promise is not the same as holding one.

The em dash and ellipsis were the first non-ASCII characters TextOutput emitted;
under a C/POSIX locale stdout degrades them to '?'. Replaced with ASCII and
pinned by a test.

Also records in specs/03 why the attribute is read with getattr while the keys
inside an entry are subscripted -- they answer different questions, version skew
versus a producer violating its own contract -- since side by side they read as
an inconsistency. And 99-open-questions.md now carries the deferred floor
decision, including that raising the floor is the trigger to drop both the
getattr defence and the new skip guard.

152 tests pass.
@claude

claude Bot commented Aug 29, 2026

Copy link
Copy Markdown

Reviewed against CLAUDE.md, specs/03-formatters.md, 04-testing.md, 05-sdk-contract.md. Gitflow is clean (base develop, no version bump, ## Requires present, floor correction in 05 is right). Three things worth acting on.

1. _safe_data misses the C1 range, so the CSI it exists to stop can still get through (src/polyswarm/formatters/text.py:24)

_CONTROL_CHARS = re.compile(r'[\x00-\x1f\x7f]') covers C0 + DEL but not U+0080–U+009F. U+009B is CSI, and terminals that honour 8-bit controls in UTF-8 mode (xterm with the default allowC1Printable: false, VTE) act on it: data containing '\x9b2J' reaches the terminal as \xc2\x9b2J and clears the screen — the exact outcome the sanitiser and its comment claim to prevent. The threat model here is "the upstream escaping guarantee lives in another repo and might slip", and if it slips it slips into raw bytes, not conveniently only into the low range. [\x00-\x1f\x7f-\x9f] closes it; given the spec's own ASCII only rule, dropping everything non-ASCII ([^\x20-\x7e]) is the cleaner match to the stated invariant. test_control_characters_in_data_are_neutralised should grow a \x9b case — it currently only exercises \x1b, \r, \x00.

Related: the new spec bullet "ASCII only. TextOutput emits no non-ASCII" is not true as written — data (and tags, rule_name) pass through unfiltered — and test_output_is_ascii_only only renders the fixed ASCII _STRINGS, so it pins the literals, not the invariant. Either narrow the spec claim to "the strings this module itself emits" or make the sanitiser actually enforce it.

2. The module-level skip disables the two older-SDK tests in exactly the configuration they model (tests/hunt_matched_strings_test.py:47)

pytestmark = skipif(not _sdk_carries_the_fields()) applies to the whole module, including test_an_sdk_without_the_attribute_does_not_raise and test_older_sdk_without_the_dropped_attribute_does_not_raise. Those two don't need the fields — they build the resource and __dict__.pop the attribute, and pass fine on a floor SDK. So on polyswarm_api==4.3.0 (inside the declared pin) the getattr defence is verified by nothing, and a regression back to a bare result.matched_strings skips green there and only fails in the field. The commit message for 2db3fab calls that test "the only guard" — the skip removes it from the one install where it guards anything. Move the skipif onto the tests that actually construct populated resources rather than the module.

3. The spec's own examples contradict the ASCII rule it introduces (specs/03-formatters.md)

The [] table row shows Matched Strings: none — the rule matched… (em dash) and the dropped-count block shows … 19 more not shown, but the code emits -- and .... Since these read as literal expected output — and the section immediately below tells the reader non-ASCII degrades to ? under a C locale — they should be the ASCII the formatter actually produces. Same for the sample block in the PR description.

Minor: the getattr comments at text.py:294 and 330 say "same defence as the known-good reads below"; those reads are in artifact_instance, above.

…guard

Two defects in the previous commit, both of which left a stated protection not
actually in place.

The sanitiser blacklisted [\x00-\x1f\x7f] -- C0 and DEL -- and let U+0080-U+009F
through. U+009B IS the 8-bit CSI, honoured by xterm and VTE in UTF-8 mode, so
`\x9b2J` still cleared the terminal: a hole in the precise byte the filter was
written to block. The threat model is that the upstream escaping guarantee lives
in another repo and may slip, and bytes that slip do not stay conveniently in the
low range. Now a printable-ASCII whitelist, which also makes the ASCII-only rule
true of this field rather than only of the literals around it. The test grows a
\x9b case and a non-ASCII one.

The module-level skipif took the two older-SDK tests out of the only install
where they guard anything. They build a resource and pop the attribute off, so
they pass fine on a floor SDK -- and on that SDK a regression to a bare attribute
read would have skipped green and failed only in the field. The previous commit
called one of them "the only guard" while disabling it there. The mark is now
applied per-test, to the fifteen that genuinely need the fields.

Also corrects the ASCII claim in specs/03, which asserted TextOutput emits no
non-ASCII: server-supplied rule_name and tags pass through unfiltered and are
outside it. And the spec's example blocks showed an em dash and an ellipsis while
the code emits -- and ..., which matters because they read as literal expected
output in a section that warns non-ASCII degrades under a C locale.

Minor: two getattr comments pointed at the known-good reads as "below"; they are
above.

152 tests pass.
@claude

claude Bot commented Aug 30, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md, specs/03-formatters.md, specs/04-testing.md, specs/05-sdk-contract.md, and the paired SDK PR (polyswarm/polyswarm-api#322).

Gitflow is clean: base is develop, no pyproject.toml version bump, ## Requires present, and the branch name matches the SDK branch so $CI_COMMIT_BRANCH.zip resolves to the paired SDK and the needs_sdk_fields tests actually run here. The getattr defence and the per-test (not module-level) skip are the right calls, and the reasoning is well recorded.

Four things to action, one of them substantive.

1. The load-bearing premise is unverified for the historical list route.

The whole silent-None / loud-[] split rests on list endpoints sending an explicit null. The SDK PR pins that only for the live pair — its 05-downstream-contract.md says outright:

The historical pair follows by symmetry, not by measurement: the e2e stack does not reliably populate historical results inside a test window, so nothing pins that those routes emit either key.

historical.py:98 (historical results <hunt_id>, the list route) and historical.py:110 (the detail route) both render through TextOutput.historical_result. If the historical list route returns [] per row rather than null, every row of a large hunt gets the ~100-char "the rule matched without byte evidence" line — the exact permanent-false-alarm-on-every-row failure this design exists to avoid, arriving through the branch that was deliberately kept loud. Nothing here would catch it: the cassettes predate the field, and the Style-3 tests write the value themselves.

The verification note reads ambiguously on precisely this point — "the empty state across 25 historical results from a structural rule". Twenty-five results sounds like the list route. Please say which route that was. If it was the list route returning [], the split does not hold for historical and needs rethinking; if it was 25 individual detail fetches, the historical list route is still unmeasured and worth a sentence in specs/03, which currently states the premise flatly.

2. specs/99-open-questions.md contradicts the test file it describes.

tests/hunt_matched_strings_test.py carries a module-level skip for the same reason.

It does not, and the final commit deliberately removed one — the file comment reads "Applied per-test, NOT as a module-level pytestmark. The two older-SDK tests below build a resource and pop the attribute off, so they pass on a floor SDK -- and that is the ONE install where they guard anything."

Worth fixing rather than shrugging off as a typo, because that same section is the instruction sheet for the later floor decision ("drop both the getattr defence and the test skip") — a reader acting on it goes looking for a pytestmark that is not there, and may reintroduce exactly what that commit removed.

3. specs/05-sdk-contract.md §Version pin is now stale against the §Current floor this PR just corrected.

Two paragraphs above the new heading:

For the current floor both were read from origin/develop: version = "4.2.0" and __version__ = '4.2.0', no suffix.

"The current floor" is 4.3.0 as of the heading directly below it. Since the PR is already correcting the 4.2.0/4.3.0 drift in this section, fold this sentence in too — either re-read the pair off origin/develop for 4.3.0, or reword it as the worked example it has become.

4. identifier is missing from the ASCII-only exception list in specs/03.

The literals this module emits are ASCII, and data is filtered to printable ASCII by _safe_data. Server-supplied rule_name / tags are not filtered and are outside this claim.

string["identifier"] is interpolated raw into the entry line inside the matched-strings block — the same block test_output_is_ascii_only asserts is ASCII (it passes only because _STRINGS uses ASCII identifiers). yara's identifier grammar makes this near-impossible in practice, which is why this is a doc fix and not a code one: either name identifier alongside rule_name / tags as outside the claim, or route it through _safe_data and keep the claim true of the whole block.

Everything else checks out — the []-with-a-dropped-count fix, the printable-ASCII whitelist and its \x9b case, grouping (_depth is 0 on both paths, so the two-space indent survives is_grouped), the untouched JSONOutput / hashes formatters, and the four cassettes netting out unchanged.

`identifier` was interpolated raw inside the matched-strings block -- the same
block test_output_is_ascii_only asserts is ASCII, which passed only because the
fixture uses ASCII identifiers. Routed through _safe_data alongside `data`, so
the ASCII rule is now true of the whole block rather than needing another
exception clause. yara's identifier grammar makes a hostile value close to
impossible; this costs nothing and removes the caveat.

Three spec statements had gone stale against changes in this same PR:

- 99-open-questions said the test module "carries a module-level skip". The
  previous commit deliberately removed exactly that, and this section is the
  instruction sheet for the later floor decision -- a reader following it would
  hunt for a pytestmark that is not there and might reintroduce it.
- 05-sdk-contract's version-pin guidance still used 4.2.0 as "the current floor"
  two paragraphs above the heading this PR corrected to 4.3.0. Reframed as the
  worked example it has become, since the check is the point rather than the
  number.
- The ASCII bullet excluded rule_name and tags but not identifier.

Also records that the silent-None branch depends on list routes sending null,
and that this is measured rather than assumed: artifact-index's
test_list_serializers_never_touch_storage pins present-and-null on BOTH hunt
pairs against rows that carry evidence. This repo cannot verify it, so the spec
now names what it relies on.

152 tests pass.
@claude

claude Bot commented Aug 30, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md and specs/03-formatters.md / 04-testing.md / 05-sdk-contract.md. Correctness of the renderer looks right: the three-state branch matches the spec table, both attributes are read with getattr (correct — pyproject.toml floors at 4.3.0, which predates the fields), the entry keys are subscripted with the reasoning recorded, the [] + dropped branch does not make a claim it cannot support, and JSONOutput/hashes.py genuinely need no change since they dump .json / digests. Gitflow is correct: base is develop, no pyproject.toml version bump, ## Requires links the SDK PR. Three things.

1. Test coverage — the yellow of both dropped-count lines is pinned by nothing.

specs/03-formatters.md §The dropped-count line states the colour as a deliberate signal ("in yellow rather than white … it is the one line here reporting something the platform withheld"), and the empty-with-count line is yellow too. But _render in tests/hunt_matched_strings_test.py:73 builds TextOutput(color=False) and every assertion runs through click.unstyle, so both lines could regress to self._white and the whole module stays green.

This is the exact gap specs/03-formatters.md:120-126 already calls out for the known-good rendering — "The colour decision is invisible to a test that unstyles its output, so it is pinned against the styled render" — with _render_styled in known_good_field_test.py:50 as the established pattern. Two missing cases:

  • matched_strings=_STRINGS, matched_strings_dropped=19 → the trailing line equals click.style(" ... 19 more not shown (result size limit)", fg="yellow")
  • matched_strings=[], matched_strings_dropped=19 → the single line is yellow, not white

2. Spec drift inside this PR — the state table contradicts the section below it.

specs/03-formatters.md:159-163 gives [] a single unconditional rendering ("the rule matched without byte evidence …"), but with a non-zero matched_strings_dropped the code emits none shown (N withheld, result size limit) instead. A reader consulting the table — which is the part written to be consulted — gets the wrong answer for a case §The dropped-count line then argues is important enough to have its own paragraph. Split the [] row, or note the count overrides it.

3. Minor — the branch name puts a ticket ID in public history.

AGENTS.md §Commit + PR hygiene: "Don't reference ticket IDs or internal project codes in commit messages, PR titles, or PR descriptions… Track tickets in the internal tracker, not the git history." The commits, title and body are all clean, but the head ref is DN-8378-yara-matched-strings, and a GitHub merge commit renders as Merge pull request #267 from polyswarm/DN-8378-… — the ID lands in develop's history anyway. Squash-merge (or rename the branch) before merging.

specs/03 states the colour as a deliberate signal -- it is the one line in the
block reporting something the platform did not send -- but every assertion in
this module ran through TextOutput(color=False) and click.unstyle, so both lines
could have regressed to white with the suite staying green.

Adds a _render_styled helper mirroring the established pattern in
known_good_field_test.py, and three cases: each withheld line is yellow, and the
ordinary block is not -- without that last one the first two would pass on a
formatter that painted everything.

Also splits the `[]` row of the state table, which gave a single unconditional
rendering while the section below it documents that a non-zero count overrides
that line. The table is the part written to be consulted, and it was answering
the case immediately below it incorrectly.

158 tests pass.
@claude

claude Bot commented Aug 30, 2026

Copy link
Copy Markdown

Review

Formatter change itself is correct and matches the specs it adds: three-state handled, JSONOutput rightly untouched (it dumps .json), hashes subclasses override live_result/historical_result so they are unaffected, block placed between Tags: and Download Url:, base is develop, no version bump, ## Requires present. Two things.

1. test_older_sdk_without_the_dropped_attribute_does_not_raise fails on the floor SDK it exists to cover

tests/hunt_matched_strings_test.py:209 is deliberately left unmarked — 99-open-questions.md says the mark is per-test "so the two older-SDK tests still run on a floor SDK, which is the only install where they guard anything." That holds for the sibling at :156, which asserts absence. It does not hold here: the test builds content = dict(_COMMON, matched_strings=_STRINGS), pops matched_strings_dropped, and then asserts "Matched Strings:" in rendered.

That header only renders if the installed SDK parsed matched_strings off the content dict. On 4.3.0 it does not — that is precisely why the other 18 tests in the module carry needs_sdk_fields despite constructing the field the same way, and why pip install .[tests] && pytest at the floor previously "failed a further ten". So at the declared floor this test fails, and the getattr defence for matched_strings_dropped ends up guarded by nothing on the only install where it matters — the inverse of the stated intent. CI will not show it: the archive resolves to the paired SDK branch.

Make it floor-independent the same way the sibling is, by injecting rather than relying on the SDK to parse: build the resource from _COMMON alone, then result.__dict__["matched_strings"] = _STRINGS before popping matched_strings_dropped. As written the 99-open-questions claim is false for one of the two tests; fixing the test is what keeps the spec true.

2. Branch name carries a ticket ID onto a public PR

DN-8378-yara-matched-strings. Commit messages, title and body are clean per AGENTS.md, but a merge commit would render as Merge pull request #267 from polyswarm/DN-8378-..., putting the internal reference into develop's history — the thing the rule is protecting against. Squash-merge (subject = PR title) or rename the branch before merging.

Minor

The _UNPRINTABLE / _matched_strings comments restate specs/03-formatters.md §Matched strings nearly verbatim (CSI rationale, []-vs-None, subscript-vs-getattr). Two copies of the same argument is two places to keep in sync, and several commits in this PR were already spent fixing prose that drifted between them. Consider trimming the in-code versions to the decision plus a pointer at the spec.

…cated comments

test_older_sdk_without_the_dropped_attribute_does_not_raise was left unmarked so
it would run on a floor SDK -- the only install where it guards anything -- but it
passed matched_strings through the content dict and then asserted the block
renders. That requires the SDK to have PARSED matched_strings, which 4.3.0 does
not, so it failed at the floor: the inverse of the intent, and it made the
99-open-questions claim false for one of the two tests. The field is now injected
into __dict__ instead, so the test depends on nothing the floor omits. Verified
against a stand-in resource class that sets neither field.

That is the third correction to this pair -- del raising on old SDKs, then a
module-level skip removing them from old SDKs, now a dependency on parsing. The
common cause each time was a test that models an older SDK while quietly relying
on a newer one.

Also trims the formatter comments, which restated specs/03 nearly verbatim -- the
CSI rationale, the three-state reasoning, the subscript-vs-getattr argument. Two
copies of one argument is two places to keep in sync, and this PR already spent
commits reconciling prose that drifted between them. The decisions stay in the
code; the reasoning stays in the spec, which owns it.

158 tests pass.
@claude

claude Bot commented Aug 31, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md, specs/03-formatters.md, specs/04-testing.md, specs/05-sdk-contract.md.

Verified as claimed: base is develop; no version bump (correct — the floor cannot move before the SDK is on PyPI, per the two preconditions in §Version pin); the >=4.3.0 pin matches the corrected §Current floor heading; JSONOutput.historical_result / live_result really do dump result.json, so the no-JSON-change claim holds; all six commands that render hunt results (live.py:48,57,68, historical.py:104,113,124) funnel through the two methods this PR patches, so the getattr defence is load-bearing well beyond the new output; no cassette bytes touched.

Three things.

1. length and matched_strings_dropped are server-supplied, interpolated raw, and sit inside the block the spec claims is ASCII-only — src/polyswarm/formatters/text.py:73,79,83

From specs/03-formatters.md:

ASCII only, and true of this whole block. The literals are ASCII, and both server-supplied fields inside the matched-strings block — data and identifier — go through _safe_data.

There are more than two server-supplied fields in that block. offset is safe only by accident of the :x format spec — a non-int raises ValueError rather than rendering. length and dropped are interpolated with plain str(): no coercion, no filter. A producer sending a length of 14 followed by a CSI clear-screen sequence puts that sequence on an analyst terminal from inside the very block whose stated invariant is that it cannot. That is the same threat model the _safe_data commit argued for (trusting an upstream promise is not the same as holding one), and the same one that motivated closing the C1 hole — length simply was not enumerated.

test_output_is_ascii_only cannot catch it: the string fixtures and the fixture count are both ASCII, so it passes on any implementation.

Cheapest fix, and consistent with the stance already in the file (subscript strictly, because a malformed entry is a producer breaking its own contract and crashing is correct): give both numbers a :d format spec, the same protection :x already hands offset for free. That makes the ASCII claim structurally true instead of resting on an unenumerated assumption. Then narrow the spec bullet to say the numeric fields are pinned by their format spec rather than by _safe_data.

Related, same lines: if dropped: is a truthiness test on an unvalidated value, so a stringified zero renders ... 0 more not shown (result size limit) — a withheld-count line for nothing withheld. :d does not fix that one; int(dropped or 0) > 0 does, if the shape is worth defending at all.

2. Nothing drives the populated block through the command tree — specs/04-testing.md §Style 3

A command whose rendering is covered by Style 3 still needs at least one CliRunner test proving the command reaches the formatter at all.

The letter of that is satisfied by the existing cli_test.py cassettes, but the PR is explicit that every result they render takes the silent None branch. So nothing in this repo exercises polyswarm live result actually emitting a populated block — the new output path is only ever reached by calling TextOutput.live_result directly. Specific missing case, and it needs no cassette re-recording: a Style 1 test (patch polyswarm_api.api.PolyswarmAPI.live_result to return a resource carrying matched_strings, drive live result 123 through CliRunner, assert the block in result.output) would prove the resource the SDK hands back renders through the real command tree, and would catch a regression in the output.extend wiring that all thirty Style 3 assertions would sail straight past.

3. The branch name carries an internal ticket code — DN-8378-yara-matched-strings

AGENTS.md: Do not reference ticket IDs or internal project codes in commit messages, PR titles, or PR descriptions. This repo is public. The commits, title and body are all clean — but the default merge-commit subject is Merge pull request #267 from polyswarm/DN-8378-yara-matched-strings, which lands the code in public history anyway. Squash-merge with a clean subject, or rename the branch first.

Nothing blocking on the spec side. 03 adds the rendering rule its own incremental note asks for once a resource rendering becomes non-obvious or contested; 05 records the attributes the floor does not cover; 99 carries the deferred floor decision along with the trigger to drop both the getattr defence and the mark; 02-commands.md is correctly untouched, since no command surface changed. The per-test rather than module-level needs_sdk_fields placement, and the __dict__ pop/inject in the two older-SDK tests, are right for what they model — those two are the only assertions that survive on a floor SDK, and they now depend on nothing 4.3.0 omits.

@sbneto

sbneto commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Gives a hunt hit its yara evidence: the analyzer now scans with -s -L and reports each matched string (offset, identifier, stored length, rendered bytes, truncated), bounded by a per-task byte budget that reports what it withheld. The server stores that per match in a metadata container behind a nullable FK and serves matched_strings / matched_strings_dropped on the hunt detail routes only; the SDK parses both additively and this CLI renders a block between Tags: and Download Url:. 4 PRs on one shared branch; 31 files, +2486/−1780 across the set.

Severity: 0 HIGH · 5 MODERATE · 8 LOW. Prior feedback: 35 checked · 8 open.
Objective: met, with gaps — hunt results carry the yara strings that caused the hit.

  • The tracker was unreadable this run (the connector is unauthorized here), so this is measured against the four PR bodies rather than the acceptance criteria.
  • drift: a per-task byte budget with a user-visible withheld count, not in the original scope, and well argued

Fixes are proposed, not applied; nothing was run; no independent fix review in this run.

Cross-repo coordination

Repo PR State Role
polyswarm-analyzers the analyzer-side producer change CONFLICTING producer
artifact-index polyswarm/artifact-index#1949 CONFLICTING API
polyswarm-api polyswarm/polyswarm-api#322 MERGEABLE SDK
polyswarm-cli #267 ← you are here CONFLICTING CLI

Merge order: analyzer producer → artifact-index#1949 → polyswarm-api#322 → polyswarm-cli#267 (§14: the API is the contract, and the analyzer produces the bytes). This PR merges last. The only member that can merge today is the SDK, which must merge third — the path of least resistance is the wrong order.

Surface Producer Consumer
strings / dropped on a yara match analyzer artifact-index#1949 → F4
matched_strings* JSON keys artifact-index#1949 polyswarm-api#322 ✓ verified both ways
SDK attributes + version floor polyswarm-api#322 polyswarm-cli#267 → F5

Coherence: F5's fix is a develop merge and must land before F12 and F13, which touch the same two files. The SDK's half of F5 merges before this one.

Gaps: ## Requires never names the analyzer anywhere in the chain (F4); artifact-index#1949 has no ## Requires section at all.

Findings (round 1)

Every finding below is work for this change set; each entry's Lands in: names the repo whose PR carries the fix, on the branch name every member already shares (Rule 6).

[MODERATE] F5. This PR's two conflicting files are exactly the ones develop fixed three minutes earlier

What happens: resolving the spec conflicts in favour of this branch reverts develop's move off runtime probing and restores text instructing future authors to probe. The getattr reads and 18 skip guards this PR adds become dead code guarding a configuration the merged pin forbids.
When: at conflict resolution — this PR's only two conflicts are specs/03-formatters.md and specs/05-sdk-contract.md.
Why:

  • specs/05-project-standards.md §16 bans probing a constructed object for a parsed field and bans tests skippable on a dependency's shape. This PR does both: four getattr reads (src/polyswarm/formatters/text.py:284-287, :319-322) and hasattr-driven skipif (tests/hunt_matched_strings_test.py:52, applied to 18 of 20 test functions).
  • origin/develop already carries the conforming pattern: polyswarm_api>=4.4.0 from commit 6cb030b, "pin the SDK floor instead of probing it at runtime", which deleted tests/_sdk_guards.py and 22 such decorators — landed minutes before this branch's last commit. The SDK side is done too: its origin/develop already declares 4.4.0.
  • This PR doesn't touch pyproject.toml at all, so merging develop supplies the correct floor by itself. The pin problem self-corrects; the probes and the spec text are what propagate.
  • The known-good getattr precedent this PR cites is self-refuting: specs/03-formatters.md says those attributes ship in 4.1.0, "well under the dependency floor … so every supported install has them". That guard covers a configuration the pin forbids. These guard one the pin permits — which is the difference §16 draws.

Lands in: polyswarm-cli. Also touches: polyswarm-api. Proposed fix (untested): merge origin/develop into this branch rather than hand-editing the floor, and resolve both spec conflicts in favour of develop, re-applying only this PR's genuinely new matched-strings content on top. Then replace the four getattr reads with plain attribute access and drop the two # getattr: the pin admits SDKs predating these fields comments; delete _sdk_carries_the_fields, the needs_sdk_fields mark and its 18 decorators; and delete the two not hasattr tests, whose real job survives in test_absent_renders_nothing — it renders from _COMMON, which sets no key, so the strings is None branch stays covered by a real resource instead of a mutilated one. Net 36 tests, none skipped. Keep the entry keys subscripted — that distinction (a partial entry is a producer contract violation, not version skew) is still right and should survive the rewrite. The getattr at :128-129 for state / known_good_sources is pre-existing and out of this diff; leave it. Ordering: the SDK PR merges to its develop first, and its develop → master release must precede this repo's, since that is what puts 4.4.0 on the index.

  • [LOW] F7. Comment blocks carry the design rationale §15 puts in specs — the _UNPRINTABLE / _matched_strings comments still restate specs/03-formatters.md §Matched strings, and across the set the same "-f is not a bound" argument appears at four sites in two repos. Partially acted on already by the "trim duplicated comments" commit. Lands in: polyswarm-analyzers · artifact-index · polyswarm-cli. Fix (untested): trim the in-code versions to the decision plus a pointer at the spec section you are already editing.
  • [LOW] F11. specs/03-formatters.md:171 cites the server's test_list_serializers_never_touch_storage as what pins the silent-None design — but the sibling renamed it to test_list_serializers_render_nulls_not_payloads on this same branch, precisely because the old name promised an I/O guarantee its assertions did not make. The guarantee still holds and the design is sound; only the citation is dead. Lands in: polyswarm-cli. Fix (untested): update the name. Visible only with both repos open, which is why nine single-PR rounds missed it — and worth fixing because that citation is the whole basis for the branch this spec deliberately keeps silent.
  • [LOW] F12. length and dropped are interpolated with no format spec inside the block whose spec claims the whole block is ASCII (src/polyswarm/formatters/text.py:73, :83) — offset is safe only by accident of its :x. if dropped: at :80 is also a truthiness test on an unvalidated value, though a 0 is unreachable through the real chain: the analyzer omits the key when falsy and the server's .get() then yields None. Lands in: polyswarm-cli. Fix (untested): give both numbers :d, and narrow the spec bullet to say the numeric fields are pinned by their format spec rather than by _safe_data; int(dropped or 0) > 0 if the shape is worth defending at all.
  • [LOW] F13. Nothing drives a populated block through the command tree — the module states outright it uses no CliRunner, so a regression in the output.extend wiring would sail past all 40 assertions (tests/hunt_matched_strings_test.py:3). Lands in: polyswarm-cli. Fix (untested): one Style-1 test patching PolyswarmAPI.live_result to return a resource carrying matched_strings, driving live result 123 through CliRunner, asserting the block appears in result.output. No cassette re-recording needed.

elsewhere: F1, F2, F3, F6 → polyswarm/artifact-index#1949 · F4, F10 → polyswarm/polyswarm-api#322 · F8, F9 → the analyzer PR

Outstanding review feedback

Status Raised The ask Disposition
not addressed round 9 coerce the server-supplied numbers in the ASCII block → F12
not addressed round 9 drive the populated block through the command tree → F13
partially addressed round 8 trim in-code comments that restate the spec trimmed once; four duplicated sites remain across the set → F7
open — not a defect rounds 6–9 the head ref carries an internal ticket code, so a default merge-commit subject would land it in this public repo's history. Commits, title and body are all clean. Not fixable in the diff — squash-merge with a hand-written subject, or rename the branch first. —

Two earlier points are worth recording as resolved rather than merely closed. Round 6 asked which route the "25 historical results" verification actually used, because if the historical list route returned [] per row, every row of a large hunt would carry the loud "matched without byte evidence" line. It does not — the server's ScanResultListSerializer renders a literal None for both fields, and its new test pins that for both hunt pairs. Your specs/03 premise holds; this repo simply could not verify it alone. And the round-8 floor-independence fix to test_older_sdk_without_the_dropped_attribute_does_not_raise is correct as far as it goes — F5 removes the test entirely, for a different reason.

One point I am reversing rather than confirming: round 1 asked for the getattr defence and every round since endorsed it. It was the right call against specs/03's known-good precedent, and it is the wrong call against §16, which postdates that advice by three days and which develop has already adopted in these exact files. See F5.

Standards conformity

Set-level

  • §14 delivery order — clean, and unusually so. One externally-facing capability, not a layer: producer, API, SDK and CLI in one change set, exercised through the SDK against a running stack before this interface work.
  • Rule 6 name identity — clean. All four branches are byte-identical, and e2e's _tag_friendly_branch_name lowercases exactly like CI_COMMIT_REF_SLUG, so branch CI genuinely resolves and exercises all four together today — which is also what makes the SDK archive resolve to the paired branch here. That is why F4's hazard is invisible until the first merge.
  • ## Requires linkage — ✗ → F4.

Change-level (this repo): §16 cross-repo dependencies → F5 · §15 comment the fact, specify the design → F7.

Project-level (non-clean rows only)

§ Evidence
16 ✗ runtime probes plus 18 shape-keyed test skips (F5)

polyswarm-api / polyswarm-cli — Clean: §6, §10, §15. Not applicable: §3–§5, §7–§9, §11–§13.

I did not verify §2 beyond the SDK-archive resolution F4 and F5 needed.

Checked and refuted, so not reported as a finding: that raising the floor to an unreleased 4.4.0 would break pip install polyswarm for real users. Neither repo publishes from develop, so the floor is inert until a develop → master, and develop has already carried it since before this branch's last commit.

@claude

claude Bot commented Sep 3, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md, specs/03-formatters.md, 04-testing.md, 05-sdk-contract.md. No correctness defects found in _matched_strings — the three-state branching, the []+count override, _safe_data's printable-ASCII whitelist, and the Tags: → block → Download Url: placement all match what the spec now documents, and the subscripted entry keys are the documented deliberate choice.

Gitflow / floor check out too, and I verified rather than assumed: base is develop, no pyproject.toml bump, branch name matches the SDK PR's (.gitlab-ci.yml resolves the companion by $CI_COMMIT_BRANCH), and keeping polyswarm_api>=4.4.0 is right because 4.4.0 is unreleased (latest SDK release is 4.3.0; #321 moved SDK develop to 4.4.0), so 4.4.0-as-published will contain #322. That does hinge on #322 landing before the SDK cuts 4.4.0 — the ## Requires note covers it.

Two things worth fixing:

1. historical result never reaches the formatter in any test (test coverage).
hunt_matched_strings_cli_test.py drives only live result. historical result <id> (client/historical.py:108) is the only historical route that can carry strings — the detail route — and it has no CliRunner coverage from any source: cli_test.py has cassettes for historical results (the list command) but none for the singular result, unlike live result which has test_live_result_text. So the exact wiring this module exists to pin is pinned for one of the two commands the PR changes. A second _historical_result_output helper mocking polyswarm_api.api.PolyswarmAPI.historical_result is a few lines and closes it.

2. test_live_result_stays_silent_when_nothing_was_reported can pass vacuously.
It asserts only 'Matched Strings' not in output. CliRunner catches SystemExit even under catch_exceptions=False, so a usage error (argument renamed, subcommand moved) yields exit 2 plus usage text and the assertion still passes — the one test in the module whose failure mode is silence. Assert exit_code == 0, or pin a positive marker ('Rule: dos_stub_message' in output), alongside the absence.

Doc nits in the new specs/03-formatters.md section, since the specs are authoritative here:

  • specs/05-project-standards.md §16 doesn't exist in this repo (nor in the SDK); the general "no getattr, the floor is the guarantee" rule lives in 05-sdk-contract.md §"The floor is how this repo expresses every SDK dependency" / 04-testing.md §"The SDK floor is a version pin, not a runtime probe". Point at one of those.
  • "Two constraints, both counter-intuitive enough to be worth stating:" is followed by five bullets.
  • "It is the one line here reporting something the platform withheld" — there are now two such lines, and the empty-list-with-count line is yellow as well (test_empty_with_a_count_line_is_yellow pins it). The table row for that state doesn't mention its colour.

@sbneto sbneto left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed. 3 LOW open — historical result still reaches the formatter in no test, the silence test can pass on a usage error because the helper discards the exit code, and the new formatter spec section carries three inaccuracies (a cross-repo doc reference that resolves nowhere here, a miscount, and a claim that one line reports a withheld count when two do). Verified this round: the runtime probes are gone in favour of direct attribute reads, the floor is >=4.4.0, the numeric fields carry :d, and the sibling-test citation is corrected.

The dependency floor is now 4.4.0, which is the release that parses both
fields, so pip refuses the install the getattr defence guarded against.
specs/05-project-standards.md §16 is explicit that a consumer's need for a
library surface is a version requirement, checked once by the packaging tool
and never re-derived at runtime -- no attribute probing, and no per-test skip
guards keyed on the dependency's shape.

So the four getattr reads become plain attribute access, and the suite loses
_sdk_carries_the_fields, its 18 skip decorators and the two tests that popped
an attribute off a resource to model an install the floor now forbids. Their
real job survives in test_absent_renders_nothing, which renders from a
fixture that sets no key, so the None branch stays covered by a real resource
rather than a mutilated one. 36 tests, none skipped.

The entry keys stay subscripted, deliberately: a partial entry is a producer
violating its contract, not version skew, and the two are answering different
questions.

Also in this diff, all small:

- length and dropped gain an integer format spec, so every server-supplied
  value in a block whose spec claims ASCII is pinned by construction --
  strings by _safe_data, numbers by :x / :d. offset was safe only by accident
  of already having one.
- The server-side test cited as pinning the silent-None design was renamed on
  this same branch; the citation now names the test that exists.
- The open question about this floor is resolved, so it is removed rather
  than left describing a probe that is gone.
specs/04-testing.md is explicit that a command whose rendering is covered by
the formatter-only tier "still needs at least one CliRunner test proving the
command reaches the formatter". This adds it.

What it buys, measured rather than assumed: rewiring the command to render
through the wrong formatter method leaves all 36 assertions in the sibling
module passing -- they invoke the formatter themselves and cannot see the
command tree above it -- and fails this module. The cassettes cannot catch it
either, since they predate the field and every result they render takes the
silent None branch.

Uses real SDK resources rather than mocks: the formatter reads polyscore and
the rest of the row, so a bare stub dies before reaching the block under test.
@kyle-buchmiller
kyle-buchmiller force-pushed the DN-8378-yara-matched-strings branch from 54ef680 to 28fe88c Compare September 8, 2026 17:00
@claude

claude Bot commented Sep 8, 2026

Copy link
Copy Markdown

Review — against AGENTS.md + specs/

The rendering itself is solid and the spec work is unusually thorough. One blocking issue, and it is on the version contract rather than the code.


1. The floor does not cover the new SDK surface, and an open release PR is about to freeze 4.4.0 without it — blocking

pyproject.toml:25 stays at polyswarm_api>=4.4.0. That floor was declared by SDK #321 for the hunt-page tracking fields, before matched_strings existed, and the paired SDK PR (#322) adds matched_strings / matched_strings_dropped to resources.py without touching the SDK version. Meanwhile SDK #323 "Release 4.4.0" is already open (develop → master) and its changelog lists only the hunt-page work. Newest SDK on PyPI is 4.3.0.

So if #323 merges before #322, PyPI publishes a 4.4.0 that satisfies >=4.4.0 and has neither attribute. Since 523aede removed the getattr guards, result.matched_strings at src/polyswarm/formatters/text.py:295 and :328 then raises AttributeError on every text-mode hunt command — live feed, live result, live results-delete and the three historical equivalents — not just on the new block. >= never recovers from it either, since a later 4.5.0 does not exclude 4.4.0.

specs/05-sdk-contract.md §"Raising the floor is the whole procedure", step 2, is explicit about why:

Bump the SDK's version in that same PR — minor for an additive surface. The floor here cannot name a version the SDK has not declared, so this is the one case where a feature PR carries the bump rather than the release step.

That step is what makes specs/04-testing.md §"The SDK floor is a version pin, not a runtime probe" — "A test never asks the installed SDK whether it has a feature. The pin guarantees it." — true. As it stands nothing guarantees it, and the removal of the probe was justified by a floor that does not name a version carrying the fields.

Either:

  • bump the SDK to 4.5.0 in #322 and pin polyswarm_api>=4.5.0 here (the documented path), or
  • if 4.4.0 is meant to carry these fields, say so in ## Requires: #322 must land on the SDK's develop before #323 merges. The PR body currently only requires it "on the SDK's develop", which the release race defeats.

2. specs/05-sdk-contract.md:99 misattributes the floor — medium

The new clause makes matched_strings part of "what moved the floor to 4.4.0". It is not: the floor moved in CLI #266 / SDK #321, before the field existed. This is the sentence that makes the un-bumped pin read as already handled, so it needs correcting whichever way #1 is resolved.

3. specs/03-formatters.md:238 hard-codes the floor version — low

(`polyswarm_api>=4.4.0`, …§Current floor) re-copies the number that specs/05-sdk-contract.md:83 says lives in exactly one place — "a copy here drifted behind the pin once already" — and that the known-good section of this same file already learned not to duplicate ("repeating the number here is what let this line go stale before"). Keep the §Current floor link, drop the literal.

4. specs/03-formatters.md:240 cites a spec that does not exist — low

specs/05-project-standards.md §16 is not in this repo (specs/ has 05-sdk-contract.md; the SDK's is 05-downstream-contract.md), and nothing else references that path. The in-repo statement of that rule is specs/04-testing.md §"The SDK floor is a version pin, not a runtime probe".

5. None strings with a non-zero dropped silently discards the count — low

text.py:61-62 returns early before the dropped check, so a result with matched_strings is None and matched_strings_dropped = 19 renders nothing at all. The state table has no row for it, and the dropped-count section says the line is "Omitted entirely when the count is zero or None" — neither applies. test_dropped_line_does_not_fabricate_a_strings_block pins the silence deliberately, so this is a table gap rather than a bug; but bb98407 split the [] row precisely because "the table is the part written to be consulted", and this is the adjacent shape.

6. The None rationale in specs/03 is narrower than the contract it defers to — low

It concludes "None on a detail route means a result predating it — nothing to say". The SDK contract it names as authoritative enumerates four causes, two of which are not that: stored evidence deleted, and delete responses (live results-delete / historical results-delete are parsed through the …List classes and render through these same two methods with both fields None). Silence is still the right output; the stated reason just does not cover a detail-route result whose evidence was deleted.


Clean otherwise. Base is develop, no pyproject.toml version bump, ## Requires links the SDK PR, JSONOutput genuinely needs no change (json.py:91-95 dumps result.json), the Style 3 / Style 1 split matches specs/04-testing.md including the "still needs at least one CliRunner test" clause, and the _render_styled colour pinning follows the known_good_field_test.py precedent. The ticket-prefixed branch name is the sanctioned case (identical name on #322) — squash-merge with an explicit clean subject so DN-… stays out of public history, per AGENTS.md.

@kyle-buchmiller
kyle-buchmiller merged commit 44a50e0 into develop Sep 10, 2026
2 checks passed
@kyle-buchmiller
kyle-buchmiller deleted the DN-8378-yara-matched-strings branch September 10, 2026 17:35
@claude claude Bot mentioned this pull request Sep 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants