@xagents_in/unmask 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +220 -86
- package/dist/cli/batch.mjs +218 -0
- package/dist/cli/main.mjs +4 -0
- package/dist/cli/scan.mjs +128 -0
- package/dist/core/registry-facts.mjs +342 -0
- package/dist/core/report-exploration.mjs +328 -0
- package/dist/server/app.mjs +6 -0
- package/dist/server/registry-facts.mjs +118 -0
- package/dist/server/report-exploration.mjs +87 -0
- package/dist/version.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,31 +1,171 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
1
3
|
# UNMASK
|
|
2
4
|
|
|
3
|
-
See what actually shipped.
|
|
5
|
+
### See what actually shipped.
|
|
6
|
+
|
|
7
|
+
**The code you review on GitHub is not the code you install from npm.**<br>
|
|
8
|
+
UNMASK compares the two, byte for byte, and shows the evidence for every difference.
|
|
9
|
+
|
|
10
|
+
[](https://www.npmjs.com/package/@xagents_in/unmask)
|
|
11
|
+
[](LICENSE)
|
|
12
|
+
[](.nvmrc)
|
|
13
|
+
[](HACKATHON.md)
|
|
14
|
+
|
|
15
|
+
[**Try the website**](https://unmask-web-386156642190.asia-south1.run.app) ·
|
|
16
|
+
[Analysis API](https://unmask-386156642190.asia-south1.run.app/version) ·
|
|
17
|
+
[npm package](https://www.npmjs.com/package/@xagents_in/unmask) ·
|
|
18
|
+
[Engineering docs](docs/README.md)
|
|
19
|
+
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## The problem in 30 seconds
|
|
25
|
+
|
|
26
|
+
Developers review source code. Machines install the **published tarball**. Nothing
|
|
27
|
+
forces the two to match: npm checks the tarball's hash, never its source.
|
|
28
|
+
|
|
29
|
+
Attackers know this. Some of the worst supply-chain attacks hid their payload
|
|
30
|
+
**only in the published package**:
|
|
31
|
+
|
|
32
|
+
| Year | Package | What shipped that was never committed |
|
|
33
|
+
| ---- | ----------------------- | ------------------------------------------ |
|
|
34
|
+
| 2018 | `flatmap-stream 0.1.1` | A payload only in the minified npm file |
|
|
35
|
+
| 2018 | `eslint-scope 3.7.2` | A `postinstall` that stole `.npmrc` tokens |
|
|
36
|
+
| 2024 | `xz-utils 5.6.0` | A backdoor only in the release tarballs |
|
|
37
|
+
| 2025 | `@ctrl/tinycolor 4.1.1` | A `bundle.js` added by the Shai-Hulud worm |
|
|
38
|
+
|
|
39
|
+
Existing tools answer other questions. Vulnerability databases ask _"is this
|
|
40
|
+
version known bad?"_. Behaviour scanners ask _"does it look malicious?"_. npm
|
|
41
|
+
provenance asks _"where was it built?"_. **UNMASK asks: "what shipped that is not in
|
|
42
|
+
the source, and where exactly?"**
|
|
43
|
+
|
|
44
|
+
## Try it in one minute
|
|
45
|
+
|
|
46
|
+
**On the web:** open the [website](https://unmask-web-386156642190.asia-south1.run.app),
|
|
47
|
+
type an exact release such as `chalk@6.0.1`, and press **check it**.
|
|
48
|
+
|
|
49
|
+
**From a terminal:**
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
npx @xagents_in/unmask check chalk@6.0.1 --out ./unmask-report
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
You get `report.json` and an escaped `report.html` in `./unmask-report`. Only the
|
|
56
|
+
package name and version leave your machine; the hosted service does the rest.
|
|
57
|
+
|
|
58
|
+
## What a check returns
|
|
59
|
+
|
|
60
|
+
A real report for **`zod@4.6.7`**, from the live service:
|
|
61
|
+
|
|
62
|
+
| What UNMASK found | Result |
|
|
63
|
+
| ---------------------------------------- | ------------------------------------------------ |
|
|
64
|
+
| Published files compared with the source | **840**, against the commit the package claims |
|
|
65
|
+
| Byte-identical to the source | **335** |
|
|
66
|
+
| In the package but not in the source | **505**, mostly build output, each one listed |
|
|
67
|
+
| AI explanations | **3**, every quotation checked against the bytes |
|
|
68
|
+
| Policy result | Exit `0`: no review rule fired. Never "safe" |
|
|
69
|
+
|
|
70
|
+
Alongside the report, `/r/<id>.details.json` gives a package profile: declared
|
|
71
|
+
entry points, direct dependencies, install scripts, static capabilities,
|
|
72
|
+
provenance, coverage, and the [OSV](https://osv.dev) advisories for that exact
|
|
73
|
+
version. This view is merged on `main` but not yet on the live service.
|
|
4
74
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
bytes, artifact-only files, uninspected content, and observations to review.
|
|
8
|
-
A completed report is not a safety verdict.
|
|
75
|
+
Every file lands in exactly one state, and every claim points to a byte range in a
|
|
76
|
+
hashed file:
|
|
9
77
|
|
|
10
|
-
|
|
11
|
-
|
|
78
|
+
| State | Meaning |
|
|
79
|
+
| ---------------- | ------------------------------------------------------------- |
|
|
80
|
+
| `byte-identical` | Same size and SHA-256 as its source file |
|
|
81
|
+
| `changed` | A source twin exists, the bytes differ, and the span is shown |
|
|
82
|
+
| `artifact-only` | In the package, nowhere in the selected source |
|
|
83
|
+
| `not-compared` | UNMASK could not judge, and says why. Nothing is hidden |
|
|
12
84
|
|
|
13
|
-
##
|
|
85
|
+
## How it works
|
|
14
86
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
[
|
|
22
|
-
|
|
87
|
+
```mermaid
|
|
88
|
+
flowchart TD
|
|
89
|
+
A["Exact release<br/>name@version"] --> B["Fetch the npm tarball<br/>and the claimed commit"]
|
|
90
|
+
B --> C["Unpack both as data<br/>nothing is run"]
|
|
91
|
+
C --> D["Compare every file<br/>byte for byte"]
|
|
92
|
+
D --> E["Read manifests<br/>and syntax cues"]
|
|
93
|
+
E --> F["Apply fixed rules<br/>exit 0 / 2 / 3"]
|
|
94
|
+
F --> G["Report: JSON and<br/>escaped HTML"]
|
|
95
|
+
F -. "only if something differs" .-> H["AI explains the evidence<br/>quotes checked"]
|
|
96
|
+
H -.-> G
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
1. **Name an exact release.** Tags, ranges and URLs are refused, so every result can
|
|
100
|
+
be reproduced.
|
|
101
|
+
2. **Fetch both, run neither.** The tarball comes from `registry.npmjs.org` and the
|
|
102
|
+
source from GitHub at the full commit the package claims. No install script,
|
|
103
|
+
build or test ever runs.
|
|
104
|
+
3. **Evidence for every difference.** Each published file is set beside its source
|
|
105
|
+
twin. The verdict comes from fixed rules, before any AI is involved.
|
|
106
|
+
|
|
107
|
+
| Exit | Meaning |
|
|
108
|
+
| ---- | ------------------------------------------------------------------------- |
|
|
109
|
+
| `0` | Analysis completed and no review rule fired. This is not a safety verdict |
|
|
110
|
+
| `3` | A review rule fired, for example `lifecycle-source-absent` |
|
|
111
|
+
| `2` | Incomplete analysis or failed input. Findings stay in the report |
|
|
112
|
+
| `1` | Internal error |
|
|
113
|
+
|
|
114
|
+
Review rules: `integrity-mismatch`, `identity-claim-conflict`,
|
|
115
|
+
`lifecycle-source-absent` and `lifecycle-implicit-gyp`.
|
|
116
|
+
|
|
117
|
+
## AI that explains, and never decides
|
|
118
|
+
|
|
119
|
+
When something differs, UNMASK can ask a model to explain it. The model works
|
|
120
|
+
inside strict limits:
|
|
121
|
+
|
|
122
|
+
- **Quotes are checked.** Every quotation must match the cited bytes exactly, or
|
|
123
|
+
the hypothesis is dropped.
|
|
124
|
+
- **It cannot change the result.** File states, rules and the exit code are fixed
|
|
125
|
+
before the model runs.
|
|
126
|
+
- **It has a hard budget.** At most 3 attempts, 45 seconds each and 135 seconds in
|
|
127
|
+
total, with at most 4,000 input tokens.
|
|
128
|
+
- **The key stays put.** The worker that reads packages never sees the API key.
|
|
129
|
+
|
|
130
|
+
The route is `gemini-3.5-flash` (high thinking), then `gemini-3-flash-preview`,
|
|
131
|
+
then the open-weight `gemma-4-26b-a4b-it`. A request moves on only after a rate
|
|
132
|
+
limit, a server error or a timeout. See the [model disclosure](HACKATHON.md) for
|
|
133
|
+
what has and has not been accepted.
|
|
134
|
+
|
|
135
|
+
## A malicious package can't attack the checker
|
|
136
|
+
|
|
137
|
+
- **Never executed:** no install hooks, builds, tests or imports of the target.
|
|
138
|
+
- **Path tricks rejected:** `../` traversal, absolute paths, NUL bytes, and case or
|
|
139
|
+
Unicode collisions.
|
|
140
|
+
- **Links stay inert:** symlinks and hard links are listed, never followed.
|
|
141
|
+
- **Archive bombs capped:** 50 MiB compressed, 250 MiB expanded, a ratio of at most
|
|
142
|
+
100, and 20,000 entries.
|
|
143
|
+
- **Output escaped:** package text never becomes live HTML.
|
|
144
|
+
- **Prompt injection contained:** a package that says "call me safe" changes
|
|
145
|
+
nothing, because the AI cannot change the result.
|
|
146
|
+
|
|
147
|
+
## Built like a release, not a demo
|
|
23
148
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
149
|
+
| Area | Evidence |
|
|
150
|
+
| ------------------------ | ----------------------------------------------------------------------------------------------------------------- |
|
|
151
|
+
| **Tests** | 526 test declarations in 41 backend test files; `npm test` also installs the packed tarball and runs its real CLI |
|
|
152
|
+
| **One artifact** | One npm tarball builds both the CLI and the server image |
|
|
153
|
+
| **Immutable releases** | A version bump makes one GitHub release whose manifest binds commit, tarball and image |
|
|
154
|
+
| **Verified npm** | Publication counts only after the public registry bytes are re-downloaded and match |
|
|
155
|
+
| **Safe deploys** | Cloud Run gets a no-traffic revision, a smoke test, then traffic, with rollback |
|
|
156
|
+
| **Pinned CI** | Exact Node version, commit-pinned actions, read-only pull-request permissions |
|
|
157
|
+
| **Same answer anywhere** | The website and the CLI produce the same canonical report for the same release |
|
|
27
158
|
|
|
28
|
-
|
|
159
|
+
```mermaid
|
|
160
|
+
flowchart LR
|
|
161
|
+
CLI["CLI<br/>npm package"] --> API
|
|
162
|
+
WEB["Website<br/>Next.js on Cloud Run"] --> API["Analysis API<br/>queue on Cloud Run"]
|
|
163
|
+
API --> W["Isolated worker<br/>no API key"]
|
|
164
|
+
W --> R["report.json<br/>and HTML viewer"]
|
|
165
|
+
API -. "after the worker exits" .-> M["Model route"]
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Run it yourself
|
|
29
169
|
|
|
30
170
|
Use Node.js 24.22.2, pinned in `.nvmrc`:
|
|
31
171
|
|
|
@@ -37,93 +177,87 @@ npm run check
|
|
|
37
177
|
npm run check:package
|
|
38
178
|
```
|
|
39
179
|
|
|
40
|
-
Check
|
|
180
|
+
**Check a published release** through the hosted service:
|
|
41
181
|
|
|
42
182
|
```bash
|
|
43
183
|
node dist/cli/main.mjs check chalk@6.0.1 --out /tmp/unmask-chalk-report
|
|
44
184
|
```
|
|
45
185
|
|
|
46
|
-
Choose a fresh output
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
acquisition
|
|
50
|
-
It must be an HTTPS origin, or a loopback HTTP origin for local testing. Hosted
|
|
51
|
-
failure never falls back to local acquisition.
|
|
52
|
-
See [CLI configuration and data flow](docs/cli.md).
|
|
186
|
+
Choose a fresh output folder for each check; existing report files are never
|
|
187
|
+
replaced. `UNMASK_SERVER_URL` selects another service: an HTTPS origin, or a
|
|
188
|
+
loopback HTTP origin for local testing. A hosted failure never falls back to local
|
|
189
|
+
acquisition. See [CLI configuration and data flow](docs/cli.md).
|
|
53
190
|
|
|
54
|
-
|
|
191
|
+
**Check a local tarball offline**, for example a copy you suspect was tampered with:
|
|
55
192
|
|
|
56
193
|
```bash
|
|
57
194
|
node dist/cli/main.mjs check --offline --tarball /path/to/package.tgz --source-archive /path/to/source.tar.gz --commit FULL_40_CHARACTER_LOWERCASE_COMMIT_SHA --source-root ARCHIVE_TOP_LEVEL_DIRECTORY --source-dir '' --cache-dir /tmp/unmask-local-cache --out /tmp/unmask-local-report
|
|
58
195
|
```
|
|
59
196
|
|
|
60
|
-
`--source-dir ''`
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
Exit codes: `0` means analysis completed; `2` means analysis was incomplete or
|
|
66
|
-
input acquisition failed; `3` means a default review-policy finding; `1` means an
|
|
67
|
-
internal error. Findings remain visible when incomplete analysis takes priority.
|
|
68
|
-
Exit `0` never means a package is safe.
|
|
197
|
+
`--source-dir ''` selects the repository root; for a monorepo, give the package's
|
|
198
|
+
folder instead. Leave out all source options for an artifact-only report.
|
|
199
|
+
`--claim name@version` checks an identity claim against the package manifest.
|
|
200
|
+
Excerpts leave the machine only if you add `--allow-model` and configure a provider.
|
|
69
201
|
|
|
70
|
-
|
|
202
|
+
**Run the website locally:**
|
|
71
203
|
|
|
72
204
|
```bash
|
|
73
205
|
UNMASK_MODEL=off HOST=127.0.0.1 PORT=3000 node dist/server/main.mjs
|
|
74
206
|
```
|
|
75
207
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
208
|
+
Then visit `http://127.0.0.1:3000`. The Next.js frontend in [`web/`](web/README.md)
|
|
209
|
+
can sit in front of it with `UNMASK_API_URL=http://127.0.0.1:3000`.
|
|
210
|
+
|
|
211
|
+
## What UNMASK does not claim
|
|
212
|
+
|
|
213
|
+
Being clear about limits is part of the product.
|
|
214
|
+
|
|
215
|
+
- **Matching code can still be malicious.** If the bad code is in the source too,
|
|
216
|
+
a comparison cannot catch it.
|
|
217
|
+
- **Build output is noisy.** Bundled and transpiled files have no source twin;
|
|
218
|
+
sourcemaps and declared output locations do not prove equivalence.
|
|
219
|
+
- **Syntax cues are bounded.** TypeScript, JSX, unsupported syntax and dynamic
|
|
220
|
+
behaviour stay uninspected. "Novel" means only "not found in the inspected
|
|
221
|
+
source".
|
|
222
|
+
- **Ambiguous sources stay incomplete.** If a repository has two manifests with the
|
|
223
|
+
package's name (axios does), the CLI needs `--source-dir`; the website has no
|
|
224
|
+
override yet.
|
|
225
|
+
- **The AI is advisory.** A checked quotation proves where the model looked, not
|
|
226
|
+
that its reasoning is right. Open-weight acceptance of the Gemma tier is still
|
|
227
|
+
pending.
|
|
228
|
+
- **npm only, for now.** PyPI and continuous release monitoring are not
|
|
229
|
+
implemented.
|
|
230
|
+
|
|
231
|
+
## Read more
|
|
232
|
+
|
|
233
|
+
| Page | What it covers |
|
|
234
|
+
| ---------------------------------------------------- | --------------------------------------------------------- |
|
|
235
|
+
| [HACKATHON.md](HACKATHON.md) | Team, problem statement and the exact AI model disclosure |
|
|
236
|
+
| [Engineering docs](docs/README.md) | Live services, model evidence and the documentation map |
|
|
237
|
+
| [Architecture](docs/architecture.md) | Flows, process boundaries and job states |
|
|
238
|
+
| [Contracts](docs/contracts.md) | Inputs, report shape, exit policy and HTTP behaviour |
|
|
239
|
+
| [CLI](docs/cli.md) | Hosted and offline CLI configuration and data flow |
|
|
240
|
+
| [Releases](docs/releases.md) | Release gates and artifact verification |
|
|
241
|
+
| [Cloud Run deployment](docs/cloud-run-deployment.md) | How a release reaches production |
|
|
242
|
+
| [Website](web/README.md) | The Next.js site and its connection to the analyzer |
|
|
243
|
+
|
|
244
|
+
## Team Cappybara
|
|
245
|
+
|
|
246
|
+
Built in one night at **Hacktoberfest 2026, Hack Day Sonipat**, for
|
|
247
|
+
**Problem 04: Published Package vs Source Code Integrity Analyzer**.
|
|
248
|
+
|
|
249
|
+
| Member | Focus |
|
|
250
|
+
| ------------------ | ---------------------------------------------- |
|
|
251
|
+
| Pranay Vishwakarma | Team lead, website |
|
|
252
|
+
| Vaiditya Tanwar | Analyzer, AI route, CI, release and deployment |
|
|
253
|
+
| Vishesh Rao | CLI and npm release |
|
|
254
|
+
| Sayan Bhattacharya | Project report, QA and documentation |
|
|
119
255
|
|
|
120
256
|
## Contributing
|
|
121
257
|
|
|
122
258
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for review, test and comment conventions.
|
|
123
|
-
Every
|
|
124
|
-
permissions and code-owner enforcement must be verified on GitHub; the
|
|
125
|
-
CODEOWNERS file alone does not enforce them.
|
|
259
|
+
Every change after the initial event commit goes through a pull request.
|
|
126
260
|
|
|
127
261
|
## License
|
|
128
262
|
|
|
129
|
-
[MIT](LICENSE)
|
|
263
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { parsePackageSpec } from "../core/acquire.mjs";
|
|
2
|
+
import { LocalInputError } from "../core/pipeline.mjs";
|
|
3
|
+
import { checkHosted } from "./hosted.mjs";
|
|
4
|
+
|
|
5
|
+
const failure = (code, message) => new LocalInputError(code, message);
|
|
6
|
+
const MAX_ITEMS = 25;
|
|
7
|
+
const MAX_INPUT_BYTES = 16384;
|
|
8
|
+
const terminalValue = (value) =>
|
|
9
|
+
JSON.stringify(value).replace(
|
|
10
|
+
/[\u007f-\u009f\u2028\u2029]/gu,
|
|
11
|
+
(character) =>
|
|
12
|
+
`\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`,
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
async function readInput(stdin, signal) {
|
|
16
|
+
const controller = new AbortController();
|
|
17
|
+
const timer = setTimeout(() => controller.abort(), 5000);
|
|
18
|
+
const lifetime = signal
|
|
19
|
+
? AbortSignal.any([signal, controller.signal])
|
|
20
|
+
: controller.signal;
|
|
21
|
+
let onAbort;
|
|
22
|
+
try {
|
|
23
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
24
|
+
onAbort = () =>
|
|
25
|
+
reject(
|
|
26
|
+
failure(
|
|
27
|
+
"batch-input-cancelled",
|
|
28
|
+
"Batch stdin timed out or was cancelled before EOF.",
|
|
29
|
+
),
|
|
30
|
+
);
|
|
31
|
+
lifetime.addEventListener("abort", onAbort, { once: true });
|
|
32
|
+
if (lifetime.aborted) onAbort();
|
|
33
|
+
});
|
|
34
|
+
const reading = (async () => {
|
|
35
|
+
const chunks = [];
|
|
36
|
+
let size = 0;
|
|
37
|
+
for await (const chunk of stdin) {
|
|
38
|
+
const bytes = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
39
|
+
if (!(bytes instanceof Uint8Array))
|
|
40
|
+
throw failure("batch-input", "Batch stdin must contain UTF-8 text.");
|
|
41
|
+
size += bytes.byteLength;
|
|
42
|
+
if (size > MAX_INPUT_BYTES)
|
|
43
|
+
throw failure("batch-input-limit", "Batch stdin exceeds 16 KiB.");
|
|
44
|
+
chunks.push(bytes);
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(
|
|
48
|
+
Buffer.concat(chunks, size),
|
|
49
|
+
);
|
|
50
|
+
} catch {
|
|
51
|
+
throw failure(
|
|
52
|
+
"batch-input",
|
|
53
|
+
"Batch stdin must contain valid UTF-8 text.",
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
})();
|
|
57
|
+
return await Promise.race([reading, aborted]);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (error instanceof LocalInputError) throw error;
|
|
60
|
+
throw failure("batch-input", "Batch stdin could not be read.");
|
|
61
|
+
} finally {
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
lifetime.removeEventListener("abort", onAbort);
|
|
64
|
+
if (lifetime.aborted) stdin.destroy?.();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function inputs(argv, runtime) {
|
|
69
|
+
const specs = [];
|
|
70
|
+
const seen = new Set();
|
|
71
|
+
for (const arg of argv) {
|
|
72
|
+
if (arg.startsWith("--")) {
|
|
73
|
+
if (!["--stdin", "--json"].includes(arg) || seen.has(arg))
|
|
74
|
+
throw failure(
|
|
75
|
+
"usage",
|
|
76
|
+
"Use batch exact-spec... [--stdin] [--json], with each option once.",
|
|
77
|
+
);
|
|
78
|
+
seen.add(arg);
|
|
79
|
+
} else specs.push(arg);
|
|
80
|
+
}
|
|
81
|
+
if (specs.length > MAX_ITEMS || specs.some((spec) => spec.length > 512))
|
|
82
|
+
throw failure(
|
|
83
|
+
"batch-input-limit",
|
|
84
|
+
"Batch accepts at most 25 inputs of at most 512 characters each.",
|
|
85
|
+
);
|
|
86
|
+
if (seen.has("--stdin")) {
|
|
87
|
+
const text = await readInput(
|
|
88
|
+
runtime.stdin ?? process.stdin,
|
|
89
|
+
runtime.signal,
|
|
90
|
+
);
|
|
91
|
+
specs.push(...text.split(/\s+/u).filter(Boolean));
|
|
92
|
+
}
|
|
93
|
+
if (!specs.length)
|
|
94
|
+
throw failure(
|
|
95
|
+
"usage",
|
|
96
|
+
"Batch requires explicit exact specs from args or --stdin.",
|
|
97
|
+
);
|
|
98
|
+
if (specs.length > MAX_ITEMS || specs.some((spec) => spec.length > 512))
|
|
99
|
+
throw failure(
|
|
100
|
+
"batch-input-limit",
|
|
101
|
+
"Batch accepts at most 25 inputs of at most 512 characters each.",
|
|
102
|
+
);
|
|
103
|
+
return { specs, json: seen.has("--json") };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function runBatch(argv, runtime = {}) {
|
|
107
|
+
const { specs, json } = await inputs(argv, runtime);
|
|
108
|
+
const unique = new Map();
|
|
109
|
+
for (const [index, spec] of specs.entries()) {
|
|
110
|
+
if (!unique.has(spec)) unique.set(spec, { spec, inputIndexes: [] });
|
|
111
|
+
unique.get(spec).inputIndexes.push(index);
|
|
112
|
+
}
|
|
113
|
+
const controller = new AbortController();
|
|
114
|
+
const timer = setTimeout(() => controller.abort(), 600000);
|
|
115
|
+
const lifetime = runtime.signal
|
|
116
|
+
? AbortSignal.any([runtime.signal, controller.signal])
|
|
117
|
+
: controller.signal;
|
|
118
|
+
const items = [];
|
|
119
|
+
let paused = false;
|
|
120
|
+
try {
|
|
121
|
+
for (const item of unique.values()) {
|
|
122
|
+
const result = { ...item, occurrences: item.inputIndexes.length };
|
|
123
|
+
try {
|
|
124
|
+
parsePackageSpec(item.spec);
|
|
125
|
+
} catch {
|
|
126
|
+
items.push({
|
|
127
|
+
...result,
|
|
128
|
+
status: "error",
|
|
129
|
+
error: {
|
|
130
|
+
code: "invalid-package-spec",
|
|
131
|
+
message:
|
|
132
|
+
"Use an exact public npm package name@version; ranges, tags and URLs are unsupported.",
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (lifetime.aborted || paused) {
|
|
138
|
+
items.push({
|
|
139
|
+
...result,
|
|
140
|
+
status: "not-checked",
|
|
141
|
+
error: {
|
|
142
|
+
code: lifetime.aborted
|
|
143
|
+
? "batch-cancelled"
|
|
144
|
+
: "batch-submission-paused",
|
|
145
|
+
message: "No hosted request was submitted for this item.",
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
const report = await checkHosted(item.spec, {
|
|
152
|
+
serverUrl: (runtime.env ?? process.env).UNMASK_SERVER_URL,
|
|
153
|
+
fetchImpl: runtime.fetchImpl,
|
|
154
|
+
signal: lifetime,
|
|
155
|
+
limits: runtime.hostedLimits,
|
|
156
|
+
});
|
|
157
|
+
items.push({
|
|
158
|
+
...result,
|
|
159
|
+
status: "completed",
|
|
160
|
+
policyExitCode: report.policy.exitCode,
|
|
161
|
+
findingCount: report.policy.findings.length,
|
|
162
|
+
model: {
|
|
163
|
+
status: String(report.model?.status ?? "unavailable").slice(0, 80),
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
} catch (error) {
|
|
167
|
+
const known = error instanceof LocalInputError;
|
|
168
|
+
items.push({
|
|
169
|
+
...result,
|
|
170
|
+
status: "error",
|
|
171
|
+
error: {
|
|
172
|
+
code: known ? error.code : "batch-check-failed",
|
|
173
|
+
message: known
|
|
174
|
+
? error.message
|
|
175
|
+
: "Check failed; no completed result is claimed.",
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
if (
|
|
179
|
+
["hosted-submit-failed", "hosted-report-failed"].includes(error?.code)
|
|
180
|
+
)
|
|
181
|
+
paused = true;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
} finally {
|
|
185
|
+
clearTimeout(timer);
|
|
186
|
+
}
|
|
187
|
+
const result = {
|
|
188
|
+
schemaVersion: "1",
|
|
189
|
+
requestedCount: specs.length,
|
|
190
|
+
uniqueCount: items.length,
|
|
191
|
+
duplicateCount: specs.length - items.length,
|
|
192
|
+
completedCount: items.filter((item) => item.status === "completed").length,
|
|
193
|
+
errorCount: items.filter((item) => item.status === "error").length,
|
|
194
|
+
notCheckedCount: items.filter((item) => item.status === "not-checked")
|
|
195
|
+
.length,
|
|
196
|
+
items,
|
|
197
|
+
};
|
|
198
|
+
const exitCode =
|
|
199
|
+
result.errorCount || result.notCheckedCount
|
|
200
|
+
? 2
|
|
201
|
+
: items.some((item) => item.policyExitCode === 3)
|
|
202
|
+
? 3
|
|
203
|
+
: items.some((item) => item.policyExitCode === 2)
|
|
204
|
+
? 2
|
|
205
|
+
: 0;
|
|
206
|
+
const stdout = runtime.stdout ?? process.stdout;
|
|
207
|
+
if (json) stdout.write(JSON.stringify({ ...result, exitCode }) + "\n");
|
|
208
|
+
else {
|
|
209
|
+
for (const item of items)
|
|
210
|
+
stdout.write(
|
|
211
|
+
`${terminalValue(item.spec)} x${item.occurrences}: ${item.status}${item.status === "completed" ? `; policy exit ${item.policyExitCode}; findings ${item.findingCount}; AI status ${terminalValue(item.model.status)}` : `; ${item.error.code}`}\n`,
|
|
212
|
+
);
|
|
213
|
+
stdout.write(
|
|
214
|
+
`Batch: ${result.completedCount} completed, ${result.errorCount} errors, ${result.notCheckedCount} not checked; ${result.duplicateCount} duplicate inputs coalesced.\nNo findings do not establish safety.\n`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
return exitCode;
|
|
218
|
+
}
|
package/dist/cli/main.mjs
CHANGED
|
@@ -9,6 +9,8 @@ import { parsePackageSpec, AcquisitionError } from "../core/acquire.mjs";
|
|
|
9
9
|
import { addConfiguredModel } from "../core/online.mjs";
|
|
10
10
|
import { modelClientFromEnv } from "../model/config.mjs";
|
|
11
11
|
import { checkHosted } from "./hosted.mjs";
|
|
12
|
+
import { runScan } from "./scan.mjs";
|
|
13
|
+
import { runBatch } from "./batch.mjs";
|
|
12
14
|
|
|
13
15
|
const names = {
|
|
14
16
|
"--tarball": "tarballPath",
|
|
@@ -162,6 +164,8 @@ async function configuredModel(allowModel, runtime) {
|
|
|
162
164
|
|
|
163
165
|
export async function main(argv = process.argv.slice(2), runtime = {}) {
|
|
164
166
|
try {
|
|
167
|
+
if (argv[0] === "scan") return await runScan(argv.slice(1), runtime);
|
|
168
|
+
if (argv[0] === "batch") return await runBatch(argv.slice(1), runtime);
|
|
165
169
|
const { out, offline, allowModel, registrySpec, ...options } = parse(argv);
|
|
166
170
|
let report;
|
|
167
171
|
if (registrySpec)
|