@xagents_in/unmask 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +128 -2
- package/dist/cli/hosted.mjs +251 -0
- package/dist/cli/main.mjs +210 -0
- package/dist/core/acquire.mjs +283 -0
- package/dist/core/archive.mjs +485 -0
- package/dist/core/compare.mjs +230 -0
- package/dist/core/manifests.mjs +548 -0
- package/dist/core/online.mjs +310 -0
- package/dist/core/pipeline.mjs +345 -0
- package/dist/core/policy.mjs +92 -0
- package/dist/core/report.mjs +506 -0
- package/dist/core/resolve.mjs +186 -0
- package/dist/core/source.mjs +106 -0
- package/dist/core/syntax.mjs +340 -0
- package/dist/model/client.mjs +1257 -0
- package/dist/model/config.mjs +90 -0
- package/dist/server/app.mjs +299 -0
- package/dist/server/main.mjs +146 -0
- package/dist/server/queue.mjs +177 -0
- package/dist/server/runner.mjs +280 -0
- package/dist/server/store.mjs +105 -0
- package/dist/server/worker.mjs +57 -0
- package/dist/version.json +1 -0
- package/dist/viewer/render.mjs +255 -0
- package/dist/viewer/shell.mjs +73 -0
- package/package.json +39 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Cappybara contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,129 @@
|
|
|
1
|
-
#
|
|
1
|
+
# UNMASK
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
See what actually shipped.
|
|
4
|
+
|
|
5
|
+
UNMASK compares an npm package's published bytes with a selected source archive.
|
|
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.
|
|
9
|
+
|
|
10
|
+
Built for **Problem 04: Published Package vs Source Code Integrity Analyzer** at
|
|
11
|
+
Hacktoberfest 2026, Hack Day Sonipat. See [HACKATHON.md](HACKATHON.md).
|
|
12
|
+
|
|
13
|
+
## Status
|
|
14
|
+
|
|
15
|
+
The local candidate includes an npm CLI, a shared analyzer, a website with a
|
|
16
|
+
bounded job queue, and an optional hosted Gemma adapter. It uses plain ESM
|
|
17
|
+
JavaScript on Node.js 24.22.2. The npm package is `@xagents_in/unmask`.
|
|
18
|
+
Release `0.1.0` was published and verified; the current source candidate is
|
|
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.
|
|
23
|
+
|
|
24
|
+
The analyzer reads package contents as data. It does not install the inspected
|
|
25
|
+
package, run its scripts, follow archive links, or reproduce its build.
|
|
26
|
+
Source selection is a claim about correspondence, not proof of publisher trust.
|
|
27
|
+
|
|
28
|
+
## Run locally
|
|
29
|
+
|
|
30
|
+
Use Node.js 24.22.2, pinned in `.nvmrc`:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm ci --ignore-scripts
|
|
34
|
+
npm run build
|
|
35
|
+
npm test
|
|
36
|
+
npm run check
|
|
37
|
+
npm run check:package
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Check an exact public npm version through your configured UNMASK service:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
node dist/cli/main.mjs check chalk@6.0.1 --out /tmp/unmask-chalk-report
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Choose a fresh output directory for each check. The command writes `report.json`
|
|
47
|
+
and `report.html` and refuses to replace existing report files. Public-package
|
|
48
|
+
checks send only the exact package spec to the hosted service; the service owns
|
|
49
|
+
acquisition and model policy. `UNMASK_SERVER_URL` overrides the default service.
|
|
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).
|
|
53
|
+
|
|
54
|
+
To analyze local archives without network acquisition:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
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
|
+
```
|
|
59
|
+
|
|
60
|
+
`--source-dir ''` explicitly selects the repository root. For a monorepo, supply
|
|
61
|
+
the package's directory relative to that root. Omit all source options for an
|
|
62
|
+
artifact-only report. An optional `--claim name@version` checks an explicit
|
|
63
|
+
identity claim against the artifact manifest.
|
|
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.
|
|
69
|
+
|
|
70
|
+
Start the website locally:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
UNMASK_MODEL=off HOST=127.0.0.1 PORT=3000 node dist/server/main.mjs
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Visit `http://127.0.0.1:3000`. The website accepts exact public npm versions,
|
|
77
|
+
queues analysis, and serves the same report format as the CLI. It accepts no
|
|
78
|
+
uploads. Its development worker limits and temporary-file cleanup do not
|
|
79
|
+
constitute an operating-system security sandbox.
|
|
80
|
+
|
|
81
|
+
The Next.js frontend in [`web/`](web/README.md) can sit in front of that server.
|
|
82
|
+
Start it with `UNMASK_API_URL=http://127.0.0.1:3000`; see its README.
|
|
83
|
+
|
|
84
|
+
## Inspection limits
|
|
85
|
+
|
|
86
|
+
- File correspondence uses hashes and bounded byte comparisons. Transpilation,
|
|
87
|
+
minification, sourcemaps and declared output locations do not prove equivalence.
|
|
88
|
+
- Syntax cues use a bounded JavaScript parser. TypeScript, JSX, unsupported syntax,
|
|
89
|
+
dynamic behavior and unretained content remain uninspected.
|
|
90
|
+
- Literal novelty means only "not found in inspected source". Missing or
|
|
91
|
+
insufficient source cannot establish full-source absence.
|
|
92
|
+
- Ambiguous source-package selection is incomplete. For example, multiple
|
|
93
|
+
same-name manifests in Axios source require an explicit local source directory;
|
|
94
|
+
the website currently has no such override.
|
|
95
|
+
- This candidate covers npm only. PyPI and continuous release monitoring are
|
|
96
|
+
not implemented.
|
|
97
|
+
|
|
98
|
+
## Models and releases
|
|
99
|
+
|
|
100
|
+
Model output is advisory and cannot change deterministic policy findings. The
|
|
101
|
+
adapter has recorded-transport tests, but real endpoint availability, account
|
|
102
|
+
quotas and usefulness still require validation. Hosted CLI checks follow the
|
|
103
|
+
service's published model/data disclosure and never read a local provider key.
|
|
104
|
+
Only offline local checks use `--allow-model`; without that flag no local model
|
|
105
|
+
client is constructed. The flag alone does not configure an offline provider.
|
|
106
|
+
See [HACKATHON.md](HACKATHON.md) for the exact candidate and remaining AI gate.
|
|
107
|
+
|
|
108
|
+
`npm test` runs product and release-tooling tests, including a packed tarball
|
|
109
|
+
installed into a temporary directory and invoked through its real CLI binary.
|
|
110
|
+
`npm run check` checks formatting; it is not static type checking. Product tests
|
|
111
|
+
and local HTTP checks do not establish deployment readiness.
|
|
112
|
+
|
|
113
|
+
Release publication is enabled for the `0.1.1` source candidate, subject to CI
|
|
114
|
+
and runtime release gates. A completed immutable release with verified npm
|
|
115
|
+
publication triggers API deployment in the same workflow. Successful API
|
|
116
|
+
verification then triggers website deployment for that exact release commit.
|
|
117
|
+
See [release prerequisites](docs/releases.md). npm access is not required for
|
|
118
|
+
local builds or testing.
|
|
119
|
+
|
|
120
|
+
## Contributing
|
|
121
|
+
|
|
122
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for review, test and comment conventions.
|
|
123
|
+
Every remote change after the initial event commit goes through a PR. Repository
|
|
124
|
+
permissions and code-owner enforcement must be verified on GitHub; the
|
|
125
|
+
CODEOWNERS file alone does not enforce them.
|
|
126
|
+
|
|
127
|
+
## License
|
|
128
|
+
|
|
129
|
+
[MIT](LICENSE).
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
2
|
+
import { parsePackageSpec } from "../core/acquire.mjs";
|
|
3
|
+
import { LocalInputError } from "../core/pipeline.mjs";
|
|
4
|
+
import { validateReport } from "../core/report.mjs";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_SERVER_URL = "https://unmask-386156642190.asia-south1.run.app";
|
|
7
|
+
const defaults = Object.freeze({
|
|
8
|
+
totalTimeoutMs: 600000,
|
|
9
|
+
requestTimeoutMs: 15000,
|
|
10
|
+
maxBodyBytes: 64 * 1024 * 1024,
|
|
11
|
+
pollMs: 3000,
|
|
12
|
+
});
|
|
13
|
+
const failure = (code, message) => new LocalInputError(code, message);
|
|
14
|
+
function serverOrigin(value) {
|
|
15
|
+
if (!value)
|
|
16
|
+
throw failure(
|
|
17
|
+
"hosted-configuration",
|
|
18
|
+
"UNMASK_SERVER_URL must be a nonempty service origin.",
|
|
19
|
+
);
|
|
20
|
+
let url;
|
|
21
|
+
try {
|
|
22
|
+
url = new URL(value);
|
|
23
|
+
} catch {
|
|
24
|
+
throw failure(
|
|
25
|
+
"hosted-configuration",
|
|
26
|
+
"UNMASK_SERVER_URL must be a service origin.",
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
if (
|
|
30
|
+
typeof value !== "string" ||
|
|
31
|
+
value.length > 2048 ||
|
|
32
|
+
url.username ||
|
|
33
|
+
url.password ||
|
|
34
|
+
url.search ||
|
|
35
|
+
url.hash ||
|
|
36
|
+
url.pathname !== "/" ||
|
|
37
|
+
!(
|
|
38
|
+
url.protocol === "https:" ||
|
|
39
|
+
(url.protocol === "http:" &&
|
|
40
|
+
["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
throw failure(
|
|
44
|
+
"hosted-configuration",
|
|
45
|
+
"Use an HTTPS service origin, or loopback HTTP for local testing, without credentials, path, query or fragment.",
|
|
46
|
+
);
|
|
47
|
+
return url.origin;
|
|
48
|
+
}
|
|
49
|
+
async function discard(response) {
|
|
50
|
+
try {
|
|
51
|
+
await response.body?.cancel();
|
|
52
|
+
} catch {}
|
|
53
|
+
}
|
|
54
|
+
async function readJson(response, maxBytes) {
|
|
55
|
+
if (
|
|
56
|
+
!/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(
|
|
57
|
+
response.headers.get("content-type") ?? "",
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
throw failure("hosted-response", "The hosted service did not return JSON.");
|
|
61
|
+
const declared = response.headers.get("content-length");
|
|
62
|
+
if (
|
|
63
|
+
declared !== null &&
|
|
64
|
+
(!/^\d+$/.test(declared) || Number(declared) > maxBytes)
|
|
65
|
+
)
|
|
66
|
+
throw failure(
|
|
67
|
+
"hosted-response",
|
|
68
|
+
"The hosted response exceeds the body limit.",
|
|
69
|
+
);
|
|
70
|
+
let size = 0;
|
|
71
|
+
const chunks = [];
|
|
72
|
+
for await (const chunk of response.body ?? []) {
|
|
73
|
+
size += chunk.byteLength;
|
|
74
|
+
if (size > maxBytes)
|
|
75
|
+
throw failure(
|
|
76
|
+
"hosted-response",
|
|
77
|
+
"The hosted response exceeds the body limit.",
|
|
78
|
+
);
|
|
79
|
+
chunks.push(chunk);
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
return JSON.parse(
|
|
83
|
+
new TextDecoder("utf-8", { fatal: true }).decode(
|
|
84
|
+
Buffer.concat(chunks, size),
|
|
85
|
+
),
|
|
86
|
+
);
|
|
87
|
+
} catch {
|
|
88
|
+
throw failure("hosted-response", "The hosted response is not valid JSON.");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
export async function checkHosted(
|
|
92
|
+
spec,
|
|
93
|
+
{
|
|
94
|
+
serverUrl = DEFAULT_SERVER_URL,
|
|
95
|
+
fetchImpl = globalThis.fetch,
|
|
96
|
+
signal,
|
|
97
|
+
limits: overrides = {},
|
|
98
|
+
} = {},
|
|
99
|
+
) {
|
|
100
|
+
const claim = parsePackageSpec(spec);
|
|
101
|
+
const origin = serverOrigin(serverUrl);
|
|
102
|
+
const limits = { ...defaults, ...overrides };
|
|
103
|
+
if (
|
|
104
|
+
Object.keys(limits).some(
|
|
105
|
+
(key) =>
|
|
106
|
+
!Object.hasOwn(defaults, key) ||
|
|
107
|
+
!Number.isSafeInteger(limits[key]) ||
|
|
108
|
+
limits[key] < 1 ||
|
|
109
|
+
limits[key] > defaults[key],
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
throw failure(
|
|
113
|
+
"hosted-configuration",
|
|
114
|
+
"Hosted transport limits are invalid.",
|
|
115
|
+
);
|
|
116
|
+
const controller = new AbortController();
|
|
117
|
+
const totalTimer = setTimeout(
|
|
118
|
+
() => controller.abort(),
|
|
119
|
+
limits.totalTimeoutMs,
|
|
120
|
+
);
|
|
121
|
+
const lifetime = signal
|
|
122
|
+
? AbortSignal.any([signal, controller.signal])
|
|
123
|
+
: controller.signal;
|
|
124
|
+
async function request(url, options, consume) {
|
|
125
|
+
const attempt = new AbortController();
|
|
126
|
+
const timer = setTimeout(() => attempt.abort(), limits.requestTimeoutMs);
|
|
127
|
+
let response;
|
|
128
|
+
try {
|
|
129
|
+
response = await fetchImpl(url, {
|
|
130
|
+
...options,
|
|
131
|
+
redirect: "manual",
|
|
132
|
+
credentials: "omit",
|
|
133
|
+
referrerPolicy: "no-referrer",
|
|
134
|
+
signal: AbortSignal.any([lifetime, attempt.signal]),
|
|
135
|
+
});
|
|
136
|
+
if (
|
|
137
|
+
response.redirected ||
|
|
138
|
+
(response.url && new URL(response.url).origin !== origin)
|
|
139
|
+
)
|
|
140
|
+
throw failure(
|
|
141
|
+
"hosted-response",
|
|
142
|
+
"The hosted service returned an unexpected redirect.",
|
|
143
|
+
);
|
|
144
|
+
return await consume(response);
|
|
145
|
+
} finally {
|
|
146
|
+
clearTimeout(timer);
|
|
147
|
+
if (response) await discard(response);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
const reportUrl = await request(
|
|
152
|
+
origin + "/check",
|
|
153
|
+
{
|
|
154
|
+
method: "POST",
|
|
155
|
+
headers: {
|
|
156
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
157
|
+
accept: "application/json",
|
|
158
|
+
},
|
|
159
|
+
body: new URLSearchParams({ package: spec }),
|
|
160
|
+
},
|
|
161
|
+
async (response) => {
|
|
162
|
+
if (response.status !== 303)
|
|
163
|
+
throw failure(
|
|
164
|
+
"hosted-submit-failed",
|
|
165
|
+
`The hosted service did not accept the check (HTTP ${response.status}). No local fallback was attempted.`,
|
|
166
|
+
);
|
|
167
|
+
const location = response.headers.get("location");
|
|
168
|
+
let url;
|
|
169
|
+
try {
|
|
170
|
+
url = new URL(location, origin);
|
|
171
|
+
} catch {}
|
|
172
|
+
if (
|
|
173
|
+
!location ||
|
|
174
|
+
location.length > 2048 ||
|
|
175
|
+
!url ||
|
|
176
|
+
url.origin !== origin ||
|
|
177
|
+
url.username ||
|
|
178
|
+
url.password ||
|
|
179
|
+
url.search ||
|
|
180
|
+
url.hash ||
|
|
181
|
+
!/^\/r\/[a-f0-9]{32}$/.test(url.pathname)
|
|
182
|
+
)
|
|
183
|
+
throw failure(
|
|
184
|
+
"hosted-response",
|
|
185
|
+
"The hosted service returned an invalid report location.",
|
|
186
|
+
);
|
|
187
|
+
return url.href + ".json";
|
|
188
|
+
},
|
|
189
|
+
);
|
|
190
|
+
const id = new URL(reportUrl).pathname.slice(3, -5);
|
|
191
|
+
for (;;) {
|
|
192
|
+
const result = await request(
|
|
193
|
+
reportUrl,
|
|
194
|
+
{ headers: { accept: "application/json" } },
|
|
195
|
+
async (response) => {
|
|
196
|
+
if (![200, 202].includes(response.status))
|
|
197
|
+
throw failure(
|
|
198
|
+
"hosted-report-failed",
|
|
199
|
+
`The hosted report is unavailable (HTTP ${response.status}). No local fallback was attempted.`,
|
|
200
|
+
);
|
|
201
|
+
const value = await readJson(response, limits.maxBodyBytes);
|
|
202
|
+
if (response.status === 202) {
|
|
203
|
+
if (
|
|
204
|
+
value?.id !== id ||
|
|
205
|
+
!["queued", "running"].includes(value.status)
|
|
206
|
+
)
|
|
207
|
+
throw failure(
|
|
208
|
+
"hosted-response",
|
|
209
|
+
"The hosted service returned an invalid pending report.",
|
|
210
|
+
);
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
validateReport(value);
|
|
215
|
+
if (
|
|
216
|
+
value.inputs.claim?.name !== claim.name ||
|
|
217
|
+
value.inputs.claim?.version !== claim.version ||
|
|
218
|
+
![0, 2, 3].includes(value.policy.exitCode)
|
|
219
|
+
)
|
|
220
|
+
throw new TypeError();
|
|
221
|
+
} catch {
|
|
222
|
+
throw failure(
|
|
223
|
+
"hosted-report-invalid",
|
|
224
|
+
"The hosted report failed schema, identity or deterministic policy validation.",
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
return value;
|
|
228
|
+
},
|
|
229
|
+
);
|
|
230
|
+
if (result) return result;
|
|
231
|
+
await delay(limits.pollMs, undefined, { signal: lifetime });
|
|
232
|
+
}
|
|
233
|
+
} catch (error) {
|
|
234
|
+
if (error instanceof LocalInputError) throw error;
|
|
235
|
+
if (
|
|
236
|
+
lifetime.aborted ||
|
|
237
|
+
error?.name === "AbortError" ||
|
|
238
|
+
error?.name === "TimeoutError"
|
|
239
|
+
)
|
|
240
|
+
throw failure(
|
|
241
|
+
"hosted-timeout",
|
|
242
|
+
"The hosted check timed out or was cancelled. No local fallback was attempted.",
|
|
243
|
+
);
|
|
244
|
+
throw failure(
|
|
245
|
+
"hosted-unavailable",
|
|
246
|
+
"The hosted service could not be reached or its response could not be read. No local fallback was attempted.",
|
|
247
|
+
);
|
|
248
|
+
} finally {
|
|
249
|
+
clearTimeout(totalTimer);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdir, open, unlink } from "node:fs/promises";
|
|
3
|
+
import { realpathSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { analyzeLocal, LocalInputError } from "../core/pipeline.mjs";
|
|
7
|
+
import { renderReport } from "../viewer/render.mjs";
|
|
8
|
+
import { parsePackageSpec, AcquisitionError } from "../core/acquire.mjs";
|
|
9
|
+
import { addConfiguredModel } from "../core/online.mjs";
|
|
10
|
+
import { modelClientFromEnv } from "../model/config.mjs";
|
|
11
|
+
import { checkHosted } from "./hosted.mjs";
|
|
12
|
+
|
|
13
|
+
const names = {
|
|
14
|
+
"--tarball": "tarballPath",
|
|
15
|
+
"--source-archive": "sourceArchivePath",
|
|
16
|
+
"--commit": "commit",
|
|
17
|
+
"--source-root": "sourceRoot",
|
|
18
|
+
"--source-dir": "sourceDir",
|
|
19
|
+
"--cache-dir": "cacheDir",
|
|
20
|
+
"--out": "out",
|
|
21
|
+
"--claim": "claim",
|
|
22
|
+
};
|
|
23
|
+
function exactClaim(spec) {
|
|
24
|
+
try {
|
|
25
|
+
return parsePackageSpec(spec);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (error instanceof AcquisitionError)
|
|
28
|
+
throw new LocalInputError(error.code, error.message);
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function parse(argv) {
|
|
33
|
+
if (argv[0] !== "check")
|
|
34
|
+
throw new LocalInputError(
|
|
35
|
+
"usage",
|
|
36
|
+
"Use check name@version or an explicit local tarball with --offline",
|
|
37
|
+
);
|
|
38
|
+
const options = {};
|
|
39
|
+
let start = 1;
|
|
40
|
+
if (argv[1] && !argv[1].startsWith("--")) {
|
|
41
|
+
exactClaim(argv[1]);
|
|
42
|
+
options.registrySpec = argv[1];
|
|
43
|
+
start = 2;
|
|
44
|
+
}
|
|
45
|
+
const seen = new Set();
|
|
46
|
+
for (let index = start; index < argv.length; index++) {
|
|
47
|
+
const key = argv[index];
|
|
48
|
+
if (seen.has(key))
|
|
49
|
+
throw new LocalInputError(
|
|
50
|
+
"duplicate-option",
|
|
51
|
+
"Each option may be supplied only once",
|
|
52
|
+
);
|
|
53
|
+
seen.add(key);
|
|
54
|
+
if (key === "--offline") {
|
|
55
|
+
options.offline = true;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (key === "--allow-model") {
|
|
59
|
+
options.allowModel = true;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (!Object.hasOwn(names, key))
|
|
63
|
+
throw new LocalInputError(
|
|
64
|
+
"unknown-option",
|
|
65
|
+
"Only the documented check options are supported",
|
|
66
|
+
);
|
|
67
|
+
const value = argv[++index];
|
|
68
|
+
if (
|
|
69
|
+
value === undefined ||
|
|
70
|
+
value.startsWith("--") ||
|
|
71
|
+
(!value && key !== "--source-dir")
|
|
72
|
+
)
|
|
73
|
+
throw new LocalInputError(
|
|
74
|
+
"missing-value",
|
|
75
|
+
"An option is missing its value",
|
|
76
|
+
);
|
|
77
|
+
options[names[key]] = value;
|
|
78
|
+
}
|
|
79
|
+
if (!options.out)
|
|
80
|
+
throw new LocalInputError("usage", "Check requires --out DIR");
|
|
81
|
+
if (options.registrySpec) {
|
|
82
|
+
if (
|
|
83
|
+
options.offline ||
|
|
84
|
+
options.allowModel ||
|
|
85
|
+
[
|
|
86
|
+
"tarballPath",
|
|
87
|
+
"sourceArchivePath",
|
|
88
|
+
"commit",
|
|
89
|
+
"sourceRoot",
|
|
90
|
+
"sourceDir",
|
|
91
|
+
"claim",
|
|
92
|
+
].some((key) => Object.hasOwn(options, key))
|
|
93
|
+
)
|
|
94
|
+
throw new LocalInputError(
|
|
95
|
+
"conflicting-mode",
|
|
96
|
+
"Hosted checks cannot be combined with local, offline or --allow-model options",
|
|
97
|
+
);
|
|
98
|
+
} else if (!options.offline)
|
|
99
|
+
throw new LocalInputError(
|
|
100
|
+
"usage",
|
|
101
|
+
"Local tarball check requires --offline",
|
|
102
|
+
);
|
|
103
|
+
if (!options.registrySpec && !options.cacheDir)
|
|
104
|
+
throw new LocalInputError(
|
|
105
|
+
"usage",
|
|
106
|
+
"Offline check requires --cache-dir DIR and --out DIR",
|
|
107
|
+
);
|
|
108
|
+
if (options.claim !== undefined) options.claim = exactClaim(options.claim);
|
|
109
|
+
return options;
|
|
110
|
+
}
|
|
111
|
+
async function writeReports(directory, report) {
|
|
112
|
+
const json = JSON.stringify(report, null, 2) + "\n";
|
|
113
|
+
const html = renderReport(report);
|
|
114
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
115
|
+
const created = [];
|
|
116
|
+
const handles = [];
|
|
117
|
+
try {
|
|
118
|
+
for (const filename of ["report.json", "report.html"]) {
|
|
119
|
+
const destination = path.join(directory, filename);
|
|
120
|
+
const handle = await open(destination, "wx", 0o600);
|
|
121
|
+
handles.push(handle);
|
|
122
|
+
created.push(destination);
|
|
123
|
+
}
|
|
124
|
+
await handles[0].writeFile(json);
|
|
125
|
+
await handles[1].writeFile(html);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
await Promise.allSettled(handles.map((handle) => handle.close()));
|
|
128
|
+
handles.length = 0;
|
|
129
|
+
await Promise.allSettled(created.map((filename) => unlink(filename)));
|
|
130
|
+
if (typeof error.code === "string" && /^E[A-Z]+$/.test(error.code))
|
|
131
|
+
throw new LocalInputError(
|
|
132
|
+
"output-write-failed",
|
|
133
|
+
"Output requires a writable directory without existing report files",
|
|
134
|
+
);
|
|
135
|
+
throw error;
|
|
136
|
+
} finally {
|
|
137
|
+
await Promise.allSettled(handles.map((handle) => handle.close()));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function configuredModel(allowModel, runtime) {
|
|
142
|
+
if (!allowModel) return undefined;
|
|
143
|
+
if (Object.hasOwn(runtime, "model")) return runtime.model ?? null;
|
|
144
|
+
try {
|
|
145
|
+
const factory = runtime.modelFactory ?? modelClientFromEnv;
|
|
146
|
+
const client = await factory(runtime.env ?? process.env);
|
|
147
|
+
if (client === undefined || client === null) return null;
|
|
148
|
+
if (typeof client.explain !== "function")
|
|
149
|
+
throw new TypeError("Invalid model client");
|
|
150
|
+
return (report) =>
|
|
151
|
+
client.explain(report, { allowLocal: true, signal: runtime.signal });
|
|
152
|
+
} catch {
|
|
153
|
+
process.stderr.write(
|
|
154
|
+
"model-configuration-invalid: Model configuration is invalid; deterministic analysis will continue.\n",
|
|
155
|
+
);
|
|
156
|
+
return async () => ({
|
|
157
|
+
status: "unavailable",
|
|
158
|
+
reason: "configuration-invalid",
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function main(argv = process.argv.slice(2), runtime = {}) {
|
|
164
|
+
try {
|
|
165
|
+
const { out, offline, allowModel, registrySpec, ...options } = parse(argv);
|
|
166
|
+
let report;
|
|
167
|
+
if (registrySpec)
|
|
168
|
+
report = await checkHosted(registrySpec, {
|
|
169
|
+
serverUrl: (runtime.env ?? process.env).UNMASK_SERVER_URL,
|
|
170
|
+
fetchImpl: runtime.fetchImpl,
|
|
171
|
+
signal: runtime.signal,
|
|
172
|
+
limits: runtime.hostedLimits,
|
|
173
|
+
});
|
|
174
|
+
else {
|
|
175
|
+
const model = await configuredModel(allowModel, runtime);
|
|
176
|
+
report = await analyzeLocal({ ...options, limits: runtime.limits });
|
|
177
|
+
if (allowModel) report = await addConfiguredModel(report, model);
|
|
178
|
+
if (!allowModel)
|
|
179
|
+
report = {
|
|
180
|
+
...report,
|
|
181
|
+
model: { status: "not-sent", reason: "consent-required" },
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
await writeReports(out, report);
|
|
185
|
+
process.stdout.write(`Report written to ${path.resolve(out)}\n`);
|
|
186
|
+
return report.policy.exitCode;
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (error instanceof LocalInputError) {
|
|
189
|
+
process.stderr.write(`${error.code}: ${error.message}\n`);
|
|
190
|
+
return 2;
|
|
191
|
+
}
|
|
192
|
+
process.stderr.write(
|
|
193
|
+
"Internal analysis error; no completed result is claimed.\n",
|
|
194
|
+
);
|
|
195
|
+
return 1;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function isEntrypoint(filename) {
|
|
200
|
+
if (!filename) return false;
|
|
201
|
+
try {
|
|
202
|
+
return (
|
|
203
|
+
realpathSync(fileURLToPath(import.meta.url)) === realpathSync(filename)
|
|
204
|
+
);
|
|
205
|
+
} catch {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (isEntrypoint(process.argv[1])) process.exitCode = await main();
|