0.28.0 β€” the scan can see an attack that lives in two files

octoscope 0.28.0 is out, and the integrity scan can now see an attack that lives in two files instead of one.

Here's the shape it used to miss. You read a workflow, it triggers on pull_request_target so anyone can start it, and it holds nothing worth stealing. Fine. Except the last line is uses: ./.github/workflows/build.yml with secrets: inherit β€” and that file reads your deploy token. Neither file is a finding on its own. Together they're a stranger's pull request opening a path to your secrets.

Tracing that by hand across a dozen workflows is exactly the kind of tedium nobody actually does, so 0.28.0 does it. The finding now names both halves: the trigger, and the workflow an outsider arrives through β€” you never get told a workflow_call file is fork-triggered without being shown how.

Two more things worth knowing about, both invisible in the files themselves:

A workflow that declares no permissions: doesn't run with none β€” it runs with your repository's default, which an owner can widen to read/write. octoscope now reads that setting and joins it in. And where it can't read it, the report stops claiming the workflow holds nothing, because that unreadable setting is exactly what would decide it.

Findings are also ranked heaviest-first now, so the top line is the one to act on. That one came from a reader in this very thread on the 0.27.0 launch, who noticed a +1 could sit above a +4. πŸ™

The detail I enjoyed getting right: reachability and power travel in opposite directions along a chain. Whatever can start the caller reaches everything it calls, however deep. But power doesn't accumulate β€” GitHub only ever lets a called workflow reduce what it was handed, so a workflow asking for contents: write whose caller passes nothing holds nothing. Getting that backwards would have flagged a pile of perfectly safe repos, and a security tool that cries wolf is one you learn to ignore.

brew upgrade gfazioli/tap/octoscope

Cross-repo chains are still out of scope β€” a call into another repository is reported as an unfollowed chain rather than quietly ignored, but it isn't traced. How do you audit workflow permissions today, if at all? And is the cross-repo case something you'd actually want followed?

octoscope

Site:

Newsletter:

Mastodon:

38 views

Add a comment

Replies

Best

This is the same shape as the thing I keep arguing about with agents, which is that every individual step passes review and the composition is the exploit. Your workflow example is exact: neither file is wrong on its own, and any scanner reading one file at a time will keep saying so forever. Two questions. Does the pair analysis compose past two hops, since the same trick works through a chain of three with each link looking duller than the last. And what does it do about the reverse case, where the two files are individually suspicious and the composition is actually fine, because a scanner that gets that wrong loudly is one people switch off. You shipped 0.27 and 0.28 within days of each other and the second one is a strictly harder problem. Good pace.

Β Thanks for taking the time on this β€” "every individual step passes review and the composition is the exploit" is a sharper framing than anything in my own release notes, and it generalises well past workflows.

It does compose past two hops, because it isn't pair analysis β€” every workflow on a branch goes into one set and reachability propagates to a fixpoint, so the bound is how many files there are, not how deep the chain goes. Your "each link looking duller" point doesn't buy an attacker anything either: for a file whose only trigger is workflow_call I throw away what it claims about itself, since nothing it says is decidable without a caller.

The reverse case is the same rule read backwards, and I'm glad you raised it. Reachability accumulates along a chain, power doesn't β€” GitHub only lets a callee reduce what it was handed, so one asking for contents: write whose caller passes nothing holds nothing, and doesn't score. permissions: {} in the caller is the sharp version: grants nothing, but it is a declaration, so the repository default doesn't apply either.

The axis also has a hard ceiling below the "suspicious" threshold, so capability can't move a verdict on its own however many findings a chain produces β€” that constraint is the only reason I was comfortable letting it see more. Cross-repo calls still aren't traced, though: reported as unfollowed rather than quietly skipped, which is honest but not the same as solved.

And thanks for the kind words on the pace πŸ™‚ The agents version of this is the harder problem afaik, since there isn't even a static set to take a fixpoint over. Would genuinely like to hear how you're approaching it.

Β Sorry for the slow return on this. Two answers, and the first one is unflattering.

I do not audit workflow permissions on any schedule at all. They get looked at the first time something breaks, and then never again. The reason is not laziness. There is no artifact to review. A permissions posture is not a file, it is an emergent property of a dozen files plus an org level default that does not live in the repository. Nothing to open means nobody opens it, and a quarterly reminder to "review permissions" dies because there is no page to go to. Which is why the org default reading matters more than it sounds: you are turning an invisible property into something with a location.

On cross repo, yes, but I would not want it as a finding. I would want it as a boundary marker. The valuable thing is not tracing into the other repository, it is the scan saying out loud that the chain leaves here and it stopped following. An unfollowed chain reported as unfollowed is honest. The failure would be a cross repo call that renders the same as a chain which terminated safely, which is the same collapse as everything else we have been circling: not checked and fine wearing one appearance.

Throwing away what a workflow_call file claims about itself is the sharpest decision in the release. Nothing it says is decidable without a caller, so treating its self description as evidence would have been the exact mistake.

Β The "no artifact to review" line is the actual finding here, and it reframes what the probe is for. I'd been thinking of it as one more thing to check; you're describing it as giving an invisible property a location, which is a different and better job. A quarterly reminder with no page to open is a reminder that dies, agreed.

Worth adding a measurement, because it lands better than I'd assumed: the repository-level permissions endpoint answers for org-owned repositories too, with an ordinary read token. I just checked two β€” an org repo came back write, a personal one read. The org-level endpoint, by contrast, 403s outright without admin:org. So you get the effective default at the repository, which is the level that actually applies to the workflows sitting there, without needing to be an org admin to see it. For your case that's the difference between a posture nobody can look at and one anybody on the team can.

The honest limit, since an unstated gap reads as coverage: that response tells you the effective value, not where it came from. Org policy and a repository override are indistinguishable in it. You can see the posture; you can't see its provenance. Also, the scan only mentions the default at all when some workflow actually inherits it β€” on a repo where every file declares its own, saying the setting was unread would be noise about a fact that decides nothing.

On cross-repo: that's already what it does, and your wording is better than mine. It's emitted at weight zero, so it can't move a verdict β€” the text is "calls X, which this scan does not resolve β€” what that workflow does with what it is handed was not checked". A marker, not a finding, exactly as you'd want.

But your framing catches something I'd missed, and it's a genuine gap rather than a defence: that marker is gated on the caller being reachable from a fork trigger. The reasoning was noise control β€” a repo calling a third-party reusable workflow on a tag push is ordinary, and listing every one of those is how an axis gets ignored. Read back through your sentence, though, every one of those unlisted calls renders identically to a chain that terminated safely. Which is the collapse, on the exact axis that exists to prevent it. Gating a score on reachability is right; gating the boundary marker on it isn't. Filed as , with your sentence in it as the reason β€” it also turned up an inconsistency I hadn't noticed, since the sibling disclosure for a callee-only file isn't gated at all, so two markers that both say "couldn't see past here" were following different rules for no reason πŸ˜„

ngl the workflow_call read is the one I'm keeping. That sibling disclosure is the same principle in the opposite direction: the file that can't be judged alone says so, rather than being scored on a self-description that isn't decidable afaik without a caller.