Skip to content

Releases: taipei49314/greenwash

v0.1.42

Choose a tag to compare

@taipei49314 taipei49314 released this 15 Aug 12:14

User-path correctness release:

  • greenwash.pyz now propagates standard process exits: 0 for pass, 1 for block, and 2 for input or engine errors. hook-json deliberately remains exit 0 by protocol.
  • greenwash doctor only marks the exact, tracked canonical pull_request gate healthy; ambiguous, untracked, or mutated workflow evidence fails closed.
  • Terminal and hook-json finding counts now honor the active --fail-on threshold.
  • Detector, verdict, and authority semantics were not broadened.
  • The offline doctor intentionally trusts the previous stable Action commit (v0.1.41), a one-release trust lag; a later release can advance it to v0.1.42.

v0.1.41

Choose a tag to compare

@taipei49314 taipei49314 released this 15 Aug 05:46

User-path leftovers from the visitor audit:

  • greenwash chek is an unknown command (hint: check), not a fake range
  • action/README.md no longer embeds a six-release-old SHA
  • Launch copy-paste install lines pin this tag, not v0.1.12
  • Required-check snippet SHA is this tag (7b3bc70)

v0.1.40

Choose a tag to compare

@taipei49314 taipei49314 released this 15 Aug 05:41

T3.2 bench, T3.3 compare harness, T3.4 cheat cadence, T3.6 process windows.

The tag existed; this GitHub Release is what releases/latest/download/greenwash.pyz actually serves. Engine is the v0.1.40 tag (70f1bb64).

v0.1.39

Choose a tag to compare

@taipei49314 taipei49314 released this 14 Aug 19:58

T2.2: Action input comment-pr posts one PR review comment per high finding. Soft-fails without write. Engine stays offline.

T1.8: if X: pytest.skip() -> if True: pytest.skip() now fires TEST_DISABLED. Guard text is not in the fingerprint. #86a stays info; #36 stays open.

T3.1: picked the JS/TS oracle front. *.test.js / *.spec.ts expect() matchers and test.skip reuse existing detectors. Production JS is not parsed.

v0.1.38

Choose a tag to compare

@taipei49314 taipei49314 released this 14 Aug 19:45

T2.7: blocking findings print why high: (the escalator that fired) and no de-escalator applied when none did. Next step remains the reviewed allow path. Credits that did not fire are not listed.

v0.1.37

Choose a tag to compare

@taipei49314 taipei49314 released this 14 Aug 19:40

T2.3: docs/enterprise.md (required + SARIF + allow + CODEOWNERS). doctor reports the 180-day allow cap and over-cap counts. Term footer prints allow_cap=180d.

T2.4: docs/stability.md lists frozen --format json keys and when FINDINGS_VERSION / IR_VERSION bump.

v0.1.36

Choose a tag to compare

@taipei49314 taipei49314 released this 14 Aug 19:34

T2.1: greenwash check --format sarif emits SARIF 2.1.0 for GitHub code scanning. Rule ids are greenwash rules; severity maps info→note, warn→warning, high/critical→error. Allowlisted findings omitted. Byte-identical. Exit codes unchanged.

Location startLine is 1 until the IR carries a line number; the character span is on the region.

v0.1.35

Choose a tag to compare

@taipei49314 taipei49314 released this 14 Aug 19:30

T0.4: documented gh api .../rulesets --input action/required-ruleset.json so a required check is reproducible. README step 2, action/README, doctor, and integrations.md all point at it.

v0.1.35 exists so tag-parity covers action/README.md and the doctor pointer. Composite action.yml is unchanged; the hash-pinned snippet still uses the v0.1.34 action SHA.

v0.1.24: one hop of subject dataflow, argument wraps, and one containment rule instead of two

Choose a tag to compare

@taipei49314 taipei49314 released this 12 Aug 13:51

Row 84 and the static review's Issue 7 turned out to be the same piece of work.

One containment rule, not two copies of one

_wraps existed twice — byte-identical apart from a docstring — in assert_substituted.py and subject_normalized.py. Two copies of a containment rule means the next person to widen the boundary widens one of them, and the two rules quietly disagree about what "the same subject" means with nothing failing.

It is ir/astutil.py now — same_expr, expr_wraps, argument_wraps, resolve_through — and all three detectors call it. Having one place to put the boundary is what made widening it cheap.

The wrapper hoisted one line up

- assert encode_path(s) == "caf%C3%A9"
+ got = encode_path(s).replace("%e9", "%C3%A9")
+ assert got == "caf%C3%A9"

The subject the assertion carries is just got, so containment had nothing to compare against. Resolved through the unit's own bindings, exactly once. Two hops always exist; a stated bound is the honest answer, and chasing k+1 is what the killed shell parser taught.

The wrapper on an argument

- assert encode_path(s) == "caf%C3%A9"
+ assert encode_path(normalise(s)) == "caf%C3%A9"

This launders the subject without touching it: the old call is not a sub-expression of the new one — the arguments are. Same callee, same arity, every argument unchanged or containing its counterpart, at least one actually wrapped. An argument merely replaced is refused, which is the same line expr_wraps draws for the subject.

Row 84 is partly closed and says which part: the third shape — an expected side that is an inline re-implementation — is still only covered where EXPECTED_VALUE_DERIVED reaches it.

One thing fell out for free. EXPECTED_VALUE_DERIVED compared subjects as source text, so reformatting the subject in the same commit made it skip rather than fire — recorded as a residual since v0.1.14 and closed here by simply calling the shared comparison the other two rules were already using. The copy nobody was looking at was the one that stayed broken.

And the sweep caught a false positive the fixtures could not

The first cut blocked flask daf1510a4b, with the finding:

the asserted subject was wrapped (rv -> rv)

Two defects in one line. _binding_definitions joins every right-hand side of a name assigned more than once; that test rebinds rv after the assertion, so the "definition" substituted for the subject was two expressions glued together — and it happened to contain the old one.

If greenwash cannot say which binding reaches the assertion, it does not get to guess. An ambiguous name resolves to itself now, with a unit separator for the join because a Python expression can contain a bitwise or. And the message printed the unresolved subjects, which is how it managed to claim a subject was wrapped from rv to rv.

No fixture would have caught either — both need a unit that rebinds a name after the assertion it appears in, a shape nobody writes on purpose. The 1800-commit sweep found it on the first run, which is the argument for sweeping recall-only changes that look free.

Cost

After the fix: 36 → 36 blocks, no verdict moved in either direction, opaque exemption unchanged at 24/1800, zero engine errors. Recall unchanged, enforced by the replay gate.

346 tests, dogfood clean, CI green on all 12 jobs. D-042.

pipx install git+https://github.com/taipei49314/greenwash@v0.1.24

v0.1.23: collection controls, by resolved path set

Choose a tag to compare

@taipei49314 taipei49314 released this 11 Aug 17:35

Rows 81 and 83. Three gaps in collection-control detection — and fixing one of them naively would have created a false positive worse than the bypass.

Slice targets

collect_ignore[:] = ["tests/test_billing.py"] is an ast.Subscript, not an ast.Name, so the entire form was invisible. Accepted now, for Assign and AugAssign alike.

except handlers were never walked at all

The recursion iterated ("body", "orelse", "finalbody") and required ast.stmt members. An ExceptHandler is not one, so the whole list was skipped in silence.

And here is the trap. The overwhelmingly common thing inside such a handler is:

try:
    import redis
except ImportError:
    collect_ignore.append("tests/test_redis.py")

Recording that control without a condition would have turned every optional-dependency gate in the ecosystem into an unconditional kill — the exact false positive an adversarial audit already caught this build committing once, on a PR that added the tests it was guarding.

So the handler records the condition it actually expresses: find_spec("redis") is None — the spelling the compat-gate logic already recognises, and cites in its own comment. A bare except, a different exception type, or a try body that is not a plain import records text that does not parse as a condition and therefore earns nothing. That is the fail-toward-flagging side of the same choice: an unconditional control hidden in a try still fires.

Appending to a control that already existed

Markers deduplicate by name, and the name never moves — so collect_ignore.append(...) beside an existing collect_ignore = [...] removed a whole test file with no event of any kind. The resolved set of ignored paths is compared now.

Both extra conditions on that comparison were learned by breaking existing fixtures rather than by foresight. It fires only when the control is unguarded (a growing compat gate is still a gate) and only when the marker is not itself newly added (that event already exists; reporting it twice is noise). The first cut had neither and turned three green fixtures red.

That is the second time this week a positive fixture written for a long-closed bug caught a new rule overreaching — and the argument for never deleting them.

Cost

36 → 36 blocks, no verdict moved in either direction, opaque exemption unchanged at 24/1800, zero engine errors. Recall unchanged, enforced by the replay gate.

342 tests, dogfood clean, CI green on all 12 jobs. D-041.

pipx install git+https://github.com/taipei49314/greenwash@v0.1.23