ci: scan pull requests for credentials and injection with ThreatCrush - #7
ci: scan pull requests for credentials and injection with ThreatCrush#7ralyodio wants to merge 4 commits into
Conversation
Signed-off-by: Anthony Ettinger <anthony@chovy.com>
alexanderradahl
left a comment
There was a problem hiding this comment.
Thanks for the disclosure and for being careful about pull_request, persist-credentials: false, action SHA pins, --ignore-scripts, and the npm tarball integrity check. I independently fetched @profullstack/threatcrush@0.11.2; the published SHA-512 matches the value in this workflow, and I also ran the pinned scanner against the current main tree in a disposable, scrubbed environment.
I like the idea of a lightweight report-only security check, but I don't want to merge this version as-is. Please narrow the first integration before we run/approve it:
-
Remove the compatibility converter and use 0.11.2's native SARIF path. The exact version pinned here already advertises
scan --format sarif --output ...; I verified it directly. That makes the 235-line terminal-output parser plus interface-detection branch dead compatibility surface for the only version this workflow can install. Because the dependency is exact-pinned, an upgrade is already an explicit review event. I'd rather fail on an unexpected CLI interface than own a parser for human-readable output. -
Make the first version read-only. Please start with
permissions: contents: read, a job summary, and optionally a SARIF artifact. Droppull-requests: write, the PR-comment mutation,security-events: write, and the Security-tab upload from the initial PR. Those write scopes are currently requested unconditionally even though the workflow is described as report-only. They also behave differently on fork PRs: GitHub normally downgradesGITHUB_TOKENwrites for fork-triggeredpull_requestworkflows, so the richer outputs are least reliable on the contributor PRs where this is most useful. Once the signal quality is proven, we can evaluate a separate minimal reporting step. GitHub's token behavior is documented here: https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#permissions -
Establish a useful baseline before wiring this into every PR. On current
main, the pinned scanner reports 6 findings:- two
js-uninitialized-bufferwarnings onBuffer.allocUnsafein the PTY ring, where the returned buffer is filled from retained ring bytes before use; - one environment-serialization warning in the stub MCP server's deliberate
envdumptest; - three low-severity synthetic credential findings in tests/fixtures.
These are useful scanner-quality data, but as-is the repo begins with a permanently noisy report. Please either propose narrow suppressions/baseline handling for these known intentional cases, or explain how the workflow will distinguish new findings from the accepted baseline without hiding real regressions.
- two
-
Keep the project-owned CI surface much smaller. A 382-line workflow + 235-line converter is a lot of security-sensitive code for a report-only scanner in this repository. With native SARIF, no comments, and no Security-tab mutation, this should be able to become a small auditable workflow: checkout without persisted credentials → verify/install the exact tarball → run native SARIF scan → summarize/upload artifact → fail only if the scanner itself did not run.
I have not approved the first-time-contributor workflow run yet because the workflow itself is the thing under review. If you can reduce it to that read-only/native-SARIF shape, I'll re-review the diff and then run it.
Signed-off-by: Anthony Ettinger <anthony@chovy.com>
…pack Signed-off-by: Anthony Ettinger <anthony@chovy.com>
|
Thank you — this was a careful review and every one of the four points was the 1. Converter gone, native SARIF only. You read it correctly: I did not replace the probe with an assertion, because the check you already 2. Read-only. Your point about fork PRs is the one that actually changed my mind about the 3. Baseline. I reproduced your six findings exactly, with the pinned version, Two of them are my bugs, not yours. Both
That is the rule failing to model "allocUnsafe then filled before use", which is The other four are intentional and are what suppressions are for. And on distinguishing new from baseline: the workflow already does this, and I 4. Size. 286 lines in one file, down from 382 + 235. The shape is the one you Two things I did not do, so they are not surprises. The scan still covers the I have not re-requested review; the workflow run is still yours to approve or |
|
Following up on point 3 — the The rule was a bare regex on Verified end to end against The four that remain are all in Worth being straight about the trade I made: the guard cannot prove the write This does not change the workflow in this PR; the pinned version won't pick it up Thanks again — this was the most useful review this workflow has had, and two of |
alexanderradahl
left a comment
There was a problem hiding this comment.
Thanks — the revision fixes the four things from my first review: the converter is gone, permissions are now contents: read, reporting is native SARIF/read-only, and the PR-vs-backlog split is much easier to audit. I also reproduced the top-level @profullstack/threatcrush@0.11.2 tarball integrity again.
I found three remaining trust-boundary issues before I’m comfortable approving the first-time workflow run:
-
The top-level tarball hash does not pin the code that will actually execute.
0.11.2has 11 semver-ranged runtime dependencies (^...) and its published tarball contains nonpm-shrinkwrap.json.npm install -g --ignore-scripts <verified-tarball>therefore resolves/fetches a mutable transitive tree after the verified tarball step.--ignore-scriptsis good, but whenthreatcrushruns it executes those resolved modules. For a security scanner, the “exact bytes reviewed” claim needs to extend to the runtime tree. Please either ship/use a publishable shrinkwrap in the pinned CLI release, or add a small pre-execution check that canonicalizes the resolved package-lock/dependency tree (name/version/integrity) and compares it to a maintainer-pinned digest before invoking the CLI. npm specifically documentsnpm-shrinkwrap.jsonas the publishable lockfile intended for globally installed CLI/apps. -
Keep scanner output outside the untrusted checkout.
--output threatcrush.sarifwrites to a path the PR itself can pre-create as a file/symlink, and the later Python/artifact steps consume the same checkout-controlled path. You already made the right argument for putting the npm tarball inRUNNER_TEMP; please do the same for SARIF, e.g.${RUNNER_TEMP}/threatcrush.sarif, and have the report/artifact read that exact path. The checkout should be scan input, not storage for trusted scan output. -
Changed-file classification is currently bypassable by ordinary Git filenames.
git diff --name-only ...writes newline-delimited names, then Python does.read().split(). A finding inpath with space.jsis emitted by 0.11.2 with URIpath with space.js, but the changed set becomes{path, with, space.js}, so it is incorrectly folded into “pre-existing elsewhere.” I reproduced this locally. Please use NUL-delimited paths (git diff --name-only -z) and parse withsplit(b"\\0")/os.fsdecode. While there, Markdown-escape the repo-controlled URI before putting it into the job summary so filenames containing|, backticks, or newlines cannot forge the shape of the security report.
I’m deliberately not approving run 31997099518 yet: the workflow itself is still the artifact under review. Once those three are closed, I’m happy to re-review the current head and run it.
Also: nice catch/fix upstream on the two allocUnsafe false positives. Getting the scanner from 6 to 4 intentional fixture findings is exactly the kind of signal-quality improvement I wanted from this exercise.
…e checkout Three trust-boundary fixes from review. The top-level tarball hash pinned one package, not the code that runs. 0.11.2 declares eleven semver-ranged runtime dependencies and publishes no npm-shrinkwrap.json, so `npm install -g` resolved a mutable transitive tree after the verified tarball, and that tree is what executes during a scan. The tree is now pinned in-repo: .github/threatcrush/package-lock.json names all 209 packages with an exact version and an integrity hash, `npm ci` installs that and only that, and npm checks every tarball against the hash it was pinned with. Nothing resolves at run time. tree-digest.mjs reduces that tree to one line -- sorted `name@version integrity`, sha256 over the lot -- asserted before install against TREE_DIGEST in the workflow, alongside the top-level integrity a reviewer can confirm straight from the registry. Editing a single hash in the lockfile now fails the job until the workflow constant is edited too, which is one high-contrast line in a diff. Scan output moved to RUNNER_TEMP. `--output threatcrush.sarif` resolved inside the checkout, a directory the pull request controls and can ship with that path already present as a file, a directory or a symlink; the report and artifact steps then read back from the same controlled path. The install directory moved there too, so 209 packages are not dropped into the tree under review. Changed-file classification is NUL-delimited. `git diff --name-only` plus `.read().split()` broke `path with space.js` into three names, none of which matched the SARIF URI, so a finding in a file this pull request changed was filed under "pre-existing elsewhere". Now `-z` and `split(b"\0")` with os.fsdecode. Paths in the report are also escaped, because a filename containing a pipe, a backtick or a newline could otherwise forge or hide rows in the rendered summary.
|
Thanks. All three are fixed in 1. The runtime tree, not just the top-level tarballYou're right, and it was the real hole: the hash pinned one package, not the code that executes. I took your second option, and went one step past detection: the tree is now pinned rather than resolved and then checked.
The digest is taken over the tree, not the file, so npm reformatting or reordering the lockfile does not move it; changing what executes does. The practical effect is that editing a single integrity hash fails the job until Verified against tampered copies of the lockfile: On the shrinkwrap: it belongs upstream and I'll ship 2. Scan output outside the untrusted checkoutFixed, and the same argument applied to the install directory.
3. Changed-file classificationFixed. Repo-controlled URIs and rule IDs are now escaped before going into the summary. EvidenceYou have not approved a workflow run and I am not asking you to before you have read the diff, so I ran the job locally instead: every Adding a file named The 6/3/3 backlog is the pinned scanner against current On size, since you raised itHonest accounting: the workflow went 286 to 325 lines, plus a 112-line helper. The lockfile is 2,534 lines, but it is generated data with a one-command regeneration recipe in the workflow comment, not security-sensitive logic to audit line by line, and the single Closing this is still a fine answer. Written with AI assistance. |
Adds one workflow. On each pull request it scans the checked-out repository for
hardcoded credentials, injection, SSRF and unsafe deserialisation.
.github/workflows/threatcrush-scan.ymlRead-only.
permissions:iscontents: readand nothing else. Findings goto the job summary and a SARIF artifact — no pull request comment, no Security
tab upload, no write scope requested. Those two steps are not disabled in the
file, they are absent from it. This is also the shape that keeps working on fork
pull requests, where GitHub downgrades
GITHUB_TOKENto read-only.Report-only.
failOnis empty, so findings never fail the build. An installor scan failure does fail the job: a scanner that reports clean when it did not
run is worse than no scanner.
Pre-existing findings. The report leads with findings in the files the pull
request changes and folds the rest of the repository behind a
<details>summary, so an existing backlog is visible without being posted at the author of
an unrelated change. Anything intentional can be excluded with a
.threatcrushignoreor a// threatcrush-disable-next-line <rule-id>comment.Scope: it scans the whole checked-out repository, not only the diff.
Supply chain. Pinned to
@profullstack/threatcrush@0.11.2; the tarball ishashed and checked against a value in the workflow before install (
npm viewityourself), installed with
--ignore-scripts, actions pinned to commit SHAs, andit runs on
pull_requestrather thanpull_request_target.Asked first in #6.
Disclosure: I maintain ThreatCrush;
MIT and free. Written with AI assistance. Closing this is a fine answer and I
will not send another.