@xagents_in/unmask 0.1.2 → 0.1.4
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 +301 -85
- package/dist/cli/annotations.mjs +145 -0
- package/dist/cli/audit.mjs +223 -0
- package/dist/cli/batch.mjs +218 -0
- package/dist/cli/cache.mjs +488 -0
- package/dist/cli/compare.mjs +261 -0
- package/dist/cli/config.mjs +141 -0
- package/dist/cli/discovery.mjs +372 -0
- package/dist/cli/doctor.mjs +354 -0
- package/dist/cli/exceptions.mjs +170 -0
- package/dist/cli/file.mjs +271 -0
- package/dist/cli/files.mjs +141 -0
- package/dist/cli/history.mjs +232 -0
- package/dist/cli/hosted.mjs +251 -43
- package/dist/cli/info.mjs +125 -0
- package/dist/cli/init.mjs +57 -0
- package/dist/cli/install-guard.mjs +896 -0
- package/dist/cli/lockfile.mjs +330 -0
- package/dist/cli/main.mjs +101 -14
- package/dist/cli/preflight.mjs +388 -0
- package/dist/cli/sarif.mjs +100 -0
- package/dist/cli/scan.mjs +128 -0
- package/dist/cli/shell-integration.mjs +122 -0
- package/dist/cli/terminal.mjs +396 -0
- package/dist/cli/watch.mjs +233 -0
- package/dist/cli/why.mjs +256 -0
- package/dist/core/advisories.mjs +101 -24
- package/dist/core/advisory-cache.mjs +295 -0
- package/dist/core/behavior-signals.mjs +1088 -0
- package/dist/core/dependency-graph.mjs +332 -0
- package/dist/core/evidence-exports.mjs +469 -0
- package/dist/core/guard-config.mjs +463 -0
- package/dist/core/guard-policy.mjs +308 -0
- package/dist/core/package-details.mjs +42 -6
- package/dist/core/registry-facts.mjs +342 -0
- package/dist/core/report-exploration.mjs +328 -0
- package/dist/core/report.mjs +7 -0
- package/dist/core/sarif.mjs +556 -0
- package/dist/core/syntax.mjs +296 -15
- package/dist/model/client.mjs +204 -56
- package/dist/model/diff-evidence.mjs +177 -0
- package/dist/model/summary.mjs +183 -0
- package/dist/server/advisory-result.mjs +137 -0
- package/dist/server/app.mjs +66 -2
- package/dist/server/dependency-screening.mjs +386 -0
- package/dist/server/exports.mjs +63 -0
- package/dist/server/guard-decision.mjs +37 -0
- package/dist/server/main.mjs +25 -7
- package/dist/server/queue.mjs +95 -53
- package/dist/server/registry-facts.mjs +118 -0
- package/dist/server/report-exploration.mjs +87 -0
- package/dist/server/store.mjs +21 -1
- package/dist/version.json +1 -1
- package/dist/viewer/render.mjs +10 -2
- package/dist/viewer/shell.mjs +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,31 +1,220 @@
|
|
|
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
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The terminal shows the result, coverage, findings, known advisories, the AI
|
|
56
|
+
explanation status and next steps. Add `--out ./unmask-report` to also save
|
|
57
|
+
`report.json` and an escaped `report.html`; nothing is written without it. Only the
|
|
58
|
+
package name and version leave your machine; the hosted service does the rest.
|
|
59
|
+
|
|
60
|
+
### What the terminal shows
|
|
61
|
+
|
|
62
|
+
The examples below are real CLI output from a local test server with synthetic
|
|
63
|
+
fixtures and recorded OSV advisory data. They are not live-service results.
|
|
64
|
+
|
|
65
|
+
A completed check prints the result and exit code, coverage, findings, the OSV
|
|
66
|
+
advisories for that exact version, the AI status and next steps. The AI summary
|
|
67
|
+
appears only when the model actually returned one; otherwise the line names the
|
|
68
|
+
status, such as `unavailable (quota)`, and nothing stands in for it.
|
|
69
|
+
|
|
70
|
+
```text
|
|
71
|
+
UNMASK fixture@1.0.0
|
|
72
|
+
Result Deterministic findings need review (exit 3)
|
|
73
|
+
Coverage complete: 1 file, 81 B
|
|
74
|
+
0 byte-identical, 1 changed, 0 artifact-only, 0 not-compared
|
|
75
|
+
Source selected via user-full-SHA at dddddddddddd
|
|
76
|
+
Findings 1
|
|
77
|
+
- lifecycle-source-absent (script install)
|
|
78
|
+
Advisory none reported by OSV for this exact version
|
|
79
|
+
AI complete, 0 hypotheses (separate from deterministic evidence)
|
|
80
|
+
Summary: The artifact declares an install script that the selected source does not.
|
|
81
|
+
Scope: 1 evidence items supplied, 0 omitted; not checked: runtime-behavior
|
|
82
|
+
Uncertainty: The script was not executed, so its runtime effect is unknown.
|
|
83
|
+
Next
|
|
84
|
+
- Review each finding and its cited evidence before installing.
|
|
85
|
+
- AI suggests: Compare the install script with the upstream repository before installing.
|
|
86
|
+
- Add --out DIR to save the full JSON and HTML evidence.
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
When the registry artifact cannot be analyzed, for example a removed release or
|
|
90
|
+
a timed-out run, the check still shows the advisories for that exact version. It
|
|
91
|
+
says that nothing was inspected and exits `2`:
|
|
92
|
+
|
|
93
|
+
```text
|
|
94
|
+
UNMASK ua-parser-js@0.7.29
|
|
95
|
+
Result Advisory-only: artifact not inspected; no verdict is claimed (exit 2)
|
|
96
|
+
Artifact not inspected (execution-timeout); source comparison not performed
|
|
97
|
+
Advisory 1 known for this exact version (OSV)
|
|
98
|
+
- GHSA-pjwm-rvh2-c87w Embedded malware in ua-parser-js
|
|
99
|
+
AI not run; no artifact evidence to explain
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Progress and errors go to stderr, so stdout stays clean for pipes. Color appears
|
|
103
|
+
only on an interactive terminal; set `NO_COLOR=1` to turn it off. Exit `0` means
|
|
104
|
+
no review rule fired in the inspected scope. It does not mean safe, and a
|
|
105
|
+
missing advisory is not evidence of safety.
|
|
4
106
|
|
|
5
|
-
|
|
6
|
-
It produces a JSON report and an HTML evidence viewer: matching files, changed
|
|
7
|
-
bytes, artifact-only files, uninspected content, and observations to review.
|
|
8
|
-
A completed report is not a safety verdict.
|
|
107
|
+
## What a check returns
|
|
9
108
|
|
|
10
|
-
|
|
11
|
-
Hacktoberfest 2026, Hack Day Sonipat. See [HACKATHON.md](HACKATHON.md).
|
|
109
|
+
A real report for **`zod@4.6.7`**, from the live service:
|
|
12
110
|
|
|
13
|
-
|
|
111
|
+
| What UNMASK found | Result |
|
|
112
|
+
| ---------------------------------------- | ------------------------------------------------ |
|
|
113
|
+
| Published files compared with the source | **840**, against the commit the package claims |
|
|
114
|
+
| Byte-identical to the source | **335** |
|
|
115
|
+
| In the package but not in the source | **505**, mostly build output, each one listed |
|
|
116
|
+
| AI explanations | **3**, every quotation checked against the bytes |
|
|
117
|
+
| Policy result | Exit `0`: no review rule fired. Never "safe" |
|
|
14
118
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
`0.1.1`, whose release and deployment acceptance are pending. The default service is
|
|
20
|
-
`https://unmask-386156642190.asia-south1.run.app`. See the dated
|
|
21
|
-
[deployment evidence](docs/README.md) for observed runtime versions and commits;
|
|
22
|
-
source configuration does not establish acceptance of a new release.
|
|
119
|
+
Alongside the report, `/r/<id>.details.json` gives a package profile: declared
|
|
120
|
+
entry points, direct dependencies, install scripts, static capabilities,
|
|
121
|
+
provenance, coverage, and the [OSV](https://osv.dev) advisories for that exact
|
|
122
|
+
version. This view is merged on `main` but not yet on the live service.
|
|
23
123
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
Source selection is a claim about correspondence, not proof of publisher trust.
|
|
124
|
+
Every file lands in exactly one state, and every claim points to a byte range in a
|
|
125
|
+
hashed file:
|
|
27
126
|
|
|
28
|
-
|
|
127
|
+
| State | Meaning |
|
|
128
|
+
| ---------------- | ------------------------------------------------------------- |
|
|
129
|
+
| `byte-identical` | Same size and SHA-256 as its source file |
|
|
130
|
+
| `changed` | A source twin exists, the bytes differ, and the span is shown |
|
|
131
|
+
| `artifact-only` | In the package, nowhere in the selected source |
|
|
132
|
+
| `not-compared` | UNMASK could not judge, and says why. Nothing is hidden |
|
|
133
|
+
|
|
134
|
+
## How it works
|
|
135
|
+
|
|
136
|
+
```mermaid
|
|
137
|
+
flowchart TD
|
|
138
|
+
A["Exact release<br/>name@version"] --> B["Fetch the npm tarball<br/>and the claimed commit"]
|
|
139
|
+
B --> C["Unpack both as data<br/>nothing is run"]
|
|
140
|
+
C --> D["Compare every file<br/>byte for byte"]
|
|
141
|
+
D --> E["Read manifests<br/>and syntax cues"]
|
|
142
|
+
E --> F["Apply fixed rules<br/>exit 0 / 2 / 3"]
|
|
143
|
+
F --> G["Report: JSON and<br/>escaped HTML"]
|
|
144
|
+
F -. "only if something differs" .-> H["AI explains the evidence<br/>quotes checked"]
|
|
145
|
+
H -.-> G
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
1. **Name an exact release.** Tags, ranges and URLs are refused, so every result can
|
|
149
|
+
be reproduced.
|
|
150
|
+
2. **Fetch both, run neither.** The tarball comes from `registry.npmjs.org` and the
|
|
151
|
+
source from GitHub at the full commit the package claims. No install script,
|
|
152
|
+
build or test ever runs.
|
|
153
|
+
3. **Evidence for every difference.** Each published file is set beside its source
|
|
154
|
+
twin. The verdict comes from fixed rules, before any AI is involved.
|
|
155
|
+
|
|
156
|
+
| Exit | Meaning |
|
|
157
|
+
| ---- | ------------------------------------------------------------------------- |
|
|
158
|
+
| `0` | Analysis completed and no review rule fired. This is not a safety verdict |
|
|
159
|
+
| `3` | A review rule fired, for example `lifecycle-source-absent` |
|
|
160
|
+
| `2` | Incomplete analysis or failed input. Findings stay in the report |
|
|
161
|
+
| `1` | Internal error |
|
|
162
|
+
|
|
163
|
+
Review rules: `integrity-mismatch`, `identity-claim-conflict`,
|
|
164
|
+
`lifecycle-source-absent` and `lifecycle-implicit-gyp`.
|
|
165
|
+
|
|
166
|
+
## AI that explains, and never decides
|
|
167
|
+
|
|
168
|
+
When something differs, UNMASK can ask a model to explain it. The model works
|
|
169
|
+
inside strict limits:
|
|
170
|
+
|
|
171
|
+
- **Quotes are checked.** Every quotation must match the cited bytes exactly, or
|
|
172
|
+
the hypothesis is dropped.
|
|
173
|
+
- **It cannot change the result.** File states, rules and the exit code are fixed
|
|
174
|
+
before the model runs.
|
|
175
|
+
- **It has a hard budget.** At most 3 attempts, 45 seconds each and 135 seconds in
|
|
176
|
+
total, with at most 4,000 input tokens.
|
|
177
|
+
- **The key stays put.** The worker that reads packages never sees the API key.
|
|
178
|
+
|
|
179
|
+
The route is `gemini-3.5-flash` (high thinking), then `gemini-3-flash-preview`,
|
|
180
|
+
then the open-weight `gemma-4-26b-a4b-it`. A request moves on only after a rate
|
|
181
|
+
limit, a server error or a timeout. See the [model disclosure](HACKATHON.md) for
|
|
182
|
+
what has and has not been accepted.
|
|
183
|
+
|
|
184
|
+
## A malicious package can't attack the checker
|
|
185
|
+
|
|
186
|
+
- **Never executed:** no install hooks, builds, tests or imports of the target.
|
|
187
|
+
- **Path tricks rejected:** `../` traversal, absolute paths, NUL bytes, and case or
|
|
188
|
+
Unicode collisions.
|
|
189
|
+
- **Links stay inert:** symlinks and hard links are listed, never followed.
|
|
190
|
+
- **Archive bombs capped:** 50 MiB compressed, 250 MiB expanded, a ratio of at most
|
|
191
|
+
100, and 20,000 entries.
|
|
192
|
+
- **Output escaped:** package text never becomes live HTML.
|
|
193
|
+
- **Prompt injection contained:** a package that says "call me safe" changes
|
|
194
|
+
nothing, because the AI cannot change the result.
|
|
195
|
+
|
|
196
|
+
## Built like a release, not a demo
|
|
197
|
+
|
|
198
|
+
| Area | Evidence |
|
|
199
|
+
| ------------------------ | ----------------------------------------------------------------------------------------------------------------- |
|
|
200
|
+
| **Tests** | 526 test declarations in 41 backend test files; `npm test` also installs the packed tarball and runs its real CLI |
|
|
201
|
+
| **One artifact** | One npm tarball builds both the CLI and the server image |
|
|
202
|
+
| **Immutable releases** | A version bump makes one GitHub release whose manifest binds commit, tarball and image |
|
|
203
|
+
| **Verified npm** | Publication counts only after the public registry bytes are re-downloaded and match |
|
|
204
|
+
| **Safe deploys** | Cloud Run gets a no-traffic revision, a smoke test, then traffic, with rollback |
|
|
205
|
+
| **Pinned CI** | Exact Node version, commit-pinned actions, read-only pull-request permissions |
|
|
206
|
+
| **Same answer anywhere** | The website and the CLI produce the same canonical report for the same release |
|
|
207
|
+
|
|
208
|
+
```mermaid
|
|
209
|
+
flowchart LR
|
|
210
|
+
CLI["CLI<br/>npm package"] --> API
|
|
211
|
+
WEB["Website<br/>Next.js on Cloud Run"] --> API["Analysis API<br/>queue on Cloud Run"]
|
|
212
|
+
API --> W["Isolated worker<br/>no API key"]
|
|
213
|
+
W --> R["report.json<br/>and HTML viewer"]
|
|
214
|
+
API -. "after the worker exits" .-> M["Model route"]
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
## Run it yourself
|
|
29
218
|
|
|
30
219
|
Use Node.js 24.22.2, pinned in `.nvmrc`:
|
|
31
220
|
|
|
@@ -37,93 +226,120 @@ npm run check
|
|
|
37
226
|
npm run check:package
|
|
38
227
|
```
|
|
39
228
|
|
|
40
|
-
Check
|
|
229
|
+
**Check a published release** through the hosted service:
|
|
41
230
|
|
|
42
231
|
```bash
|
|
43
232
|
node dist/cli/main.mjs check chalk@6.0.1 --out /tmp/unmask-chalk-report
|
|
44
233
|
```
|
|
45
234
|
|
|
46
|
-
|
|
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).
|
|
235
|
+
`--out` is optional. When given, choose a fresh output folder for each check;
|
|
236
|
+
existing report files are never replaced. `UNMASK_SERVER_URL` selects another service: an HTTPS origin, or a
|
|
237
|
+
loopback HTTP origin for local testing. A hosted failure never falls back to local
|
|
238
|
+
acquisition. See [CLI configuration and data flow](docs/cli.md).
|
|
53
239
|
|
|
54
|
-
|
|
240
|
+
**Check a local tarball offline**, for example a copy you suspect was tampered with:
|
|
55
241
|
|
|
56
242
|
```bash
|
|
57
243
|
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
244
|
```
|
|
59
245
|
|
|
60
|
-
`--source-dir ''`
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
246
|
+
`--source-dir ''` selects the repository root; for a monorepo, give the package's
|
|
247
|
+
folder instead. Leave out all source options for an artifact-only report.
|
|
248
|
+
`--claim name@version` checks an identity claim against the package manifest.
|
|
249
|
+
Excerpts leave the machine only if you add `--allow-model` and configure a provider.
|
|
250
|
+
|
|
251
|
+
**Commands in this build.** `node dist/cli/main.mjs --help` (or `unmask --help`
|
|
252
|
+
once installed) prints the same list, and `unmask help COMMAND` shows its usage.
|
|
64
253
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
254
|
+
| Command | What it does |
|
|
255
|
+
| ------------- | ------------------------------------------------------------------------------ |
|
|
256
|
+
| `check` | Check one exact version through the hosted service, or a local tarball offline |
|
|
257
|
+
| `install` | Refuse mutable npm install and explain the lockfile-first ci path |
|
|
258
|
+
| `ci` | Screen the exact lockfile plan before invoking npm ci |
|
|
259
|
+
| `enable` | Print explicit reversible shell integration |
|
|
260
|
+
| `disable` | Print how to remove the shell integration |
|
|
261
|
+
| `audit` | Check exact versions locked in a package-lock.json, with bounded coverage |
|
|
262
|
+
| `why` | Explain dependency paths recorded in the lockfile |
|
|
263
|
+
| `info` | Show hosted package metadata, scripts, dependencies and recorded assessment |
|
|
264
|
+
| `files` | List a bounded hosted package file inventory |
|
|
265
|
+
| `file` | Inspect recorded file evidence using a report-bound file selector |
|
|
266
|
+
| `compare` | Compare scripts, dependencies and files between two exact versions |
|
|
267
|
+
| `history` | Show a bounded registry release timeline with exact-version guidance |
|
|
268
|
+
| `config` | Manage project-local guard policy settings |
|
|
269
|
+
| `exceptions` | Manage explicit expiring exact-version guard exceptions |
|
|
270
|
+
| `init` | Create project guard configuration explicitly |
|
|
271
|
+
| `doctor` | Diagnose Node, service reachability and project guard configuration |
|
|
272
|
+
| `cache` | Import, inspect and explicitly prune local report snapshots |
|
|
273
|
+
| `sarif` | Export recorded findings as SARIF from an existing report |
|
|
274
|
+
| `annotations` | Produce escaped CI annotations and a summary from an existing report |
|
|
275
|
+
| `watch` | Opt in to watch a lockfile and check changes |
|
|
276
|
+
| `preflight` | Check changed lockfile entries against a baseline for CI |
|
|
277
|
+
| `scan` | Resolve a dist-tag to an exact version, then check it |
|
|
278
|
+
| `batch` | Check several explicit exact versions serially with duplicate coalescing |
|
|
69
279
|
|
|
70
|
-
|
|
280
|
+
The explicit install guard can block before npm when policy or incomplete evidence
|
|
281
|
+
requires it. Inspection commands report recorded evidence and coverage; exit `0`
|
|
282
|
+
never means a package is safe.
|
|
283
|
+
|
|
284
|
+
**Run the website locally:**
|
|
71
285
|
|
|
72
286
|
```bash
|
|
73
287
|
UNMASK_MODEL=off HOST=127.0.0.1 PORT=3000 node dist/server/main.mjs
|
|
74
288
|
```
|
|
75
289
|
|
|
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
|
-
|
|
290
|
+
Then visit `http://127.0.0.1:3000`. The Next.js frontend in [`web/`](web/README.md)
|
|
291
|
+
can sit in front of it with `UNMASK_API_URL=http://127.0.0.1:3000`.
|
|
292
|
+
|
|
293
|
+
## What UNMASK does not claim
|
|
294
|
+
|
|
295
|
+
Being clear about limits is part of the product.
|
|
296
|
+
|
|
297
|
+
- **Matching code can still be malicious.** If the bad code is in the source too,
|
|
298
|
+
a comparison cannot catch it.
|
|
299
|
+
- **Build output is noisy.** Bundled and transpiled files have no source twin;
|
|
300
|
+
sourcemaps and declared output locations do not prove equivalence.
|
|
301
|
+
- **Syntax cues are bounded.** TypeScript, JSX, unsupported syntax and dynamic
|
|
302
|
+
behaviour stay uninspected. "Novel" means only "not found in the inspected
|
|
303
|
+
source".
|
|
304
|
+
- **Ambiguous sources stay incomplete.** If a repository has two manifests with the
|
|
305
|
+
package's name (axios does), the CLI needs `--source-dir`; the website has no
|
|
306
|
+
override yet.
|
|
307
|
+
- **The AI is advisory.** A checked quotation proves where the model looked, not
|
|
308
|
+
that its reasoning is right. Open-weight acceptance of the Gemma tier is still
|
|
309
|
+
pending.
|
|
310
|
+
- **npm only, for now.** PyPI and continuous release monitoring are not
|
|
311
|
+
implemented.
|
|
312
|
+
|
|
313
|
+
## Read more
|
|
314
|
+
|
|
315
|
+
| Page | What it covers |
|
|
316
|
+
| ---------------------------------------------------- | --------------------------------------------------------- |
|
|
317
|
+
| [HACKATHON.md](HACKATHON.md) | Team, problem statement and the exact AI model disclosure |
|
|
318
|
+
| [Engineering docs](docs/README.md) | Live services, model evidence and the documentation map |
|
|
319
|
+
| [Architecture](docs/architecture.md) | Flows, process boundaries and job states |
|
|
320
|
+
| [Contracts](docs/contracts.md) | Inputs, report shape, exit policy and HTTP behaviour |
|
|
321
|
+
| [CLI](docs/cli.md) | Hosted and offline CLI configuration and data flow |
|
|
322
|
+
| [Releases](docs/releases.md) | Release gates and artifact verification |
|
|
323
|
+
| [Cloud Run deployment](docs/cloud-run-deployment.md) | How a release reaches production |
|
|
324
|
+
| [Website](web/README.md) | The Next.js site and its connection to the analyzer |
|
|
325
|
+
|
|
326
|
+
## Team Cappybara
|
|
327
|
+
|
|
328
|
+
Built in one night at **Hacktoberfest 2026, Hack Day Sonipat**, for
|
|
329
|
+
**Problem 04: Published Package vs Source Code Integrity Analyzer**.
|
|
330
|
+
|
|
331
|
+
| Member | Focus |
|
|
332
|
+
| ------------------ | ---------------------------------------------- |
|
|
333
|
+
| Pranay Vishwakarma | Team lead, website |
|
|
334
|
+
| Vaiditya Tanwar | Analyzer, AI route, CI, release and deployment |
|
|
335
|
+
| Vishesh Rao | CLI and npm release |
|
|
336
|
+
| Sayan Bhattacharya | Project report, QA and documentation |
|
|
119
337
|
|
|
120
338
|
## Contributing
|
|
121
339
|
|
|
122
340
|
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.
|
|
341
|
+
Every change after the initial event commit goes through a pull request.
|
|
126
342
|
|
|
127
343
|
## License
|
|
128
344
|
|
|
129
|
-
[MIT](LICENSE)
|
|
345
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { open } from "node:fs/promises";
|
|
3
|
+
import { LocalInputError } from "../core/pipeline.mjs";
|
|
4
|
+
import { collectFindings } from "../core/sarif.mjs";
|
|
5
|
+
import { parseExportOptions, readExportInputs } from "./sarif.mjs";
|
|
6
|
+
|
|
7
|
+
const MAX_SUMMARY_ITEMS = 50;
|
|
8
|
+
const commands = { error: "error", warning: "warning", note: "notice" };
|
|
9
|
+
const visible = (value) =>
|
|
10
|
+
String(value).replace(
|
|
11
|
+
/[\u0000-\u0009\u000b\u000c\u000e-\u001f\u007f-\u009f\u2028\u2029]/g,
|
|
12
|
+
(char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`,
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
export function escapeData(value) {
|
|
16
|
+
return visible(value)
|
|
17
|
+
.replace(/%/g, "%25")
|
|
18
|
+
.replace(/\r/g, "%0D")
|
|
19
|
+
.replace(/\n/g, "%0A");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function escapeProperty(value) {
|
|
23
|
+
return escapeData(value).replace(/:/g, "%3A").replace(/,/g, "%2C");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function escapeMarkdown(value) {
|
|
27
|
+
return visible(value)
|
|
28
|
+
.replace(/[\r\n]+/g, " ")
|
|
29
|
+
.replace(/[\\`*_{}[\]()<>#+!|~&"'=-]/g, (char) => `\\${char}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function where(result) {
|
|
33
|
+
const spans = result.locations.map(
|
|
34
|
+
(item) =>
|
|
35
|
+
`${item.side} ${item.path} bytes ${item.byteOffset}-${item.byteOffset + item.byteLength}`,
|
|
36
|
+
);
|
|
37
|
+
return spans.length ? ` [${spans.join("; ")}]` : "";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function annotationOutcome(collected) {
|
|
41
|
+
const { report, advisories, results } = collected;
|
|
42
|
+
let exitCode = report.policy.exitCode;
|
|
43
|
+
const knownAdvisory = results.some((item) =>
|
|
44
|
+
["advisory/known-malware", "advisory/known-vulnerability"].includes(
|
|
45
|
+
item.ruleId,
|
|
46
|
+
),
|
|
47
|
+
);
|
|
48
|
+
if (exitCode === 0 && knownAdvisory) exitCode = 3;
|
|
49
|
+
if (exitCode === 0 && advisories && !advisories.complete) exitCode = 2;
|
|
50
|
+
return exitCode;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function renderAnnotations(collected) {
|
|
54
|
+
const lines = [];
|
|
55
|
+
for (const result of collected.results)
|
|
56
|
+
lines.push(
|
|
57
|
+
`::${commands[result.level]} title=${escapeProperty(`unmask ${result.ruleId}`)}::${escapeData(result.message + where(result))}`,
|
|
58
|
+
);
|
|
59
|
+
for (const item of collected.limitations)
|
|
60
|
+
lines.push(
|
|
61
|
+
`::${commands[item.level]} title=${escapeProperty(`unmask ${item.id}`)}::${escapeData(item.message)}`,
|
|
62
|
+
);
|
|
63
|
+
return lines.join("\n") + "\n";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function renderSummary(collected, exitCode) {
|
|
67
|
+
const { report, identity, advisories, results, limitations } = collected;
|
|
68
|
+
const conflict = identity?.identityStatus === "claim-conflicts-with-manifest";
|
|
69
|
+
const name = identity
|
|
70
|
+
? conflict
|
|
71
|
+
? `${identity.name}@${identity.version} (claimed; artifact declares ${identity.declared.name}@${identity.declared.version})`
|
|
72
|
+
: `${identity.name}@${identity.version}`
|
|
73
|
+
: "unidentified artifact";
|
|
74
|
+
const count = (level) =>
|
|
75
|
+
results.filter((item) => item.level === level).length;
|
|
76
|
+
const lines = [
|
|
77
|
+
`## UNMASK check: ${escapeMarkdown(name)}`,
|
|
78
|
+
"",
|
|
79
|
+
"| Item | Value |",
|
|
80
|
+
"| --- | --- |",
|
|
81
|
+
`| Exit code | ${exitCode} |`,
|
|
82
|
+
`| Policy findings | ${report.policy.findings.length} |`,
|
|
83
|
+
`| Coverage complete | ${report.coverage.complete ? "yes" : "no"} |`,
|
|
84
|
+
`| Identity | ${escapeMarkdown(identity?.identityStatus ?? "unknown")} |`,
|
|
85
|
+
`| Advisory check | ${escapeMarkdown(advisories ? `${advisories.status}${advisories.boundTo === "claim" && identity.identityStatus !== "claim-matches-manifest" ? " (for requested identity)" : ""}` : "not supplied")} |`,
|
|
86
|
+
`| Results | ${count("error")} error, ${count("warning")} warning, ${count("note")} note |`,
|
|
87
|
+
`| Artifact sha256 | \`${report.inputs.artifact.sha256}\` |`,
|
|
88
|
+
"",
|
|
89
|
+
];
|
|
90
|
+
if (results.length) {
|
|
91
|
+
lines.push("### Results", "");
|
|
92
|
+
for (const item of results.slice(0, MAX_SUMMARY_ITEMS))
|
|
93
|
+
lines.push(
|
|
94
|
+
`- **${item.level}** \`${item.ruleId}\`: ${escapeMarkdown(item.message + where(item))}`,
|
|
95
|
+
);
|
|
96
|
+
if (results.length > MAX_SUMMARY_ITEMS)
|
|
97
|
+
lines.push(
|
|
98
|
+
`- ${results.length - MAX_SUMMARY_ITEMS} more results omitted`,
|
|
99
|
+
);
|
|
100
|
+
lines.push("");
|
|
101
|
+
}
|
|
102
|
+
lines.push("### Limitations", "");
|
|
103
|
+
for (const item of limitations)
|
|
104
|
+
lines.push(`- ${item.level}: ${escapeMarkdown(item.message)}`);
|
|
105
|
+
return lines.join("\n") + "\n\n";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function appendSummary(filename, markdown) {
|
|
109
|
+
let handle;
|
|
110
|
+
try {
|
|
111
|
+
handle = await open(
|
|
112
|
+
filename,
|
|
113
|
+
constants.O_WRONLY |
|
|
114
|
+
constants.O_APPEND |
|
|
115
|
+
constants.O_CREAT |
|
|
116
|
+
constants.O_NONBLOCK |
|
|
117
|
+
(constants.O_NOFOLLOW ?? 0),
|
|
118
|
+
0o600,
|
|
119
|
+
);
|
|
120
|
+
if (!(await handle.stat()).isFile()) throw new Error("not regular");
|
|
121
|
+
await handle.writeFile(markdown);
|
|
122
|
+
} catch {
|
|
123
|
+
throw new LocalInputError(
|
|
124
|
+
"output-write-failed",
|
|
125
|
+
"Summary requires a regular, writable file path",
|
|
126
|
+
);
|
|
127
|
+
} finally {
|
|
128
|
+
await handle?.close();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function annotationsCommand(argv, stdout = process.stdout) {
|
|
133
|
+
const options = parseExportOptions(argv, {
|
|
134
|
+
"--report": "report",
|
|
135
|
+
"--details": "details",
|
|
136
|
+
"--summary": "summary",
|
|
137
|
+
});
|
|
138
|
+
const { report, details } = await readExportInputs(options);
|
|
139
|
+
const collected = collectFindings(report, details);
|
|
140
|
+
const exitCode = annotationOutcome(collected);
|
|
141
|
+
if (options.summary !== undefined)
|
|
142
|
+
await appendSummary(options.summary, renderSummary(collected, exitCode));
|
|
143
|
+
stdout.write(renderAnnotations(collected));
|
|
144
|
+
return exitCode;
|
|
145
|
+
}
|