@inner-security/scan 0.1.0

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.
@@ -0,0 +1,808 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/args.ts
4
+ var SHORT_ALIASES = {
5
+ h: "help"
6
+ };
7
+ function parseArgs(argv) {
8
+ const out = { _: [], flags: {} };
9
+ for (let i = 0; i < argv.length; i++) {
10
+ const tok = argv[i];
11
+ if (tok.startsWith("--")) {
12
+ const eq = tok.indexOf("=");
13
+ if (eq >= 0) {
14
+ out.flags[tok.slice(2, eq)] = tok.slice(eq + 1);
15
+ } else {
16
+ const key = tok.slice(2);
17
+ const next = argv[i + 1];
18
+ if (next !== void 0 && !next.startsWith("-")) {
19
+ out.flags[key] = next;
20
+ i++;
21
+ } else {
22
+ out.flags[key] = true;
23
+ }
24
+ }
25
+ } else if (tok.startsWith("-") && tok.length > 1) {
26
+ for (const ch of tok.slice(1)) {
27
+ out.flags[SHORT_ALIASES[ch] ?? ch] = true;
28
+ }
29
+ } else {
30
+ out._.push(tok);
31
+ }
32
+ }
33
+ return out;
34
+ }
35
+ function flagString(flags, key) {
36
+ const v = flags[key];
37
+ return typeof v === "string" ? v : void 0;
38
+ }
39
+ function flagBool(flags, key) {
40
+ return flags[key] === true || flags[key] === "true";
41
+ }
42
+
43
+ // ../verdict-client/dist/client.js
44
+ var DEFAULT_API_URL = "http://localhost:8787";
45
+ function isPending(item) {
46
+ return item.action === "PENDING";
47
+ }
48
+ function isEnvelope(item) {
49
+ return !isPending(item);
50
+ }
51
+ function sleep(ms) {
52
+ return new Promise((resolve) => setTimeout(resolve, ms));
53
+ }
54
+ function stripTrailingSlash(u) {
55
+ return u.replace(/\/+$/, "");
56
+ }
57
+ var VerdictClient = class {
58
+ baseUrl;
59
+ apiKey;
60
+ timeoutMs;
61
+ maxPollAttempts;
62
+ maxRetries;
63
+ fetchImpl;
64
+ cache;
65
+ constructor(opts = {}) {
66
+ this.baseUrl = stripTrailingSlash(opts.baseUrl ?? process.env.INNER_API_URL ?? DEFAULT_API_URL);
67
+ this.apiKey = opts.apiKey ?? process.env.INNER_API_KEY;
68
+ this.timeoutMs = opts.timeoutMs ?? 1e4;
69
+ this.maxPollAttempts = opts.maxPollAttempts ?? 8;
70
+ this.maxRetries = opts.maxRetries ?? 2;
71
+ this.fetchImpl = opts.fetchImpl ?? globalThis.fetch;
72
+ this.cache = opts.cache;
73
+ if (typeof this.fetchImpl !== "function") {
74
+ throw new Error("No fetch implementation available (Node >= 18 required, or pass fetchImpl).");
75
+ }
76
+ }
77
+ headers() {
78
+ const h = { "content-type": "application/json" };
79
+ if (this.apiKey)
80
+ h.authorization = `Bearer ${this.apiKey}`;
81
+ return h;
82
+ }
83
+ /** A single fetch with a per-request AbortController timeout. */
84
+ async fetchOnce(url, init) {
85
+ const ctrl = new AbortController();
86
+ const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
87
+ try {
88
+ return await this.fetchImpl(url, { ...init, signal: ctrl.signal });
89
+ } finally {
90
+ clearTimeout(timer);
91
+ }
92
+ }
93
+ /**
94
+ * fetch with timeout + retries-with-backoff on transient failures (network
95
+ * error / timeout / 5xx). 404 is returned as-is (the poll path treats it as
96
+ * "not ready yet"); other 4xx are returned as-is for the caller to handle.
97
+ */
98
+ async fetchWithRetry(url, init) {
99
+ let lastErr;
100
+ let wait = 250;
101
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
102
+ try {
103
+ const res = await this.fetchOnce(url, init);
104
+ if (res.status >= 500 && attempt < this.maxRetries) {
105
+ await sleep(wait);
106
+ wait = Math.min(wait * 2, 5e3);
107
+ continue;
108
+ }
109
+ return res;
110
+ } catch (err) {
111
+ lastErr = err;
112
+ if (attempt < this.maxRetries) {
113
+ await sleep(wait);
114
+ wait = Math.min(wait * 2, 5e3);
115
+ continue;
116
+ }
117
+ }
118
+ }
119
+ throw lastErr instanceof Error ? lastErr : new Error(`Request to ${url} failed`);
120
+ }
121
+ /** Raw C1 batch call. Items may come back as VerdictEnvelope or PendingResponse. */
122
+ async getVerdicts(items) {
123
+ const body = { items };
124
+ const res = await this.fetchWithRetry(`${this.baseUrl}/v1/verdict`, {
125
+ method: "POST",
126
+ headers: this.headers(),
127
+ body: JSON.stringify(body)
128
+ });
129
+ if (!res.ok) {
130
+ throw new Error(`Verdict API POST /v1/verdict failed: ${res.status} ${res.statusText}`);
131
+ }
132
+ return await res.json();
133
+ }
134
+ /**
135
+ * Poll a PENDING job to resolution. Returns the resolved item (may still be
136
+ * PENDING if it never resolves within maxPollAttempts). 404 is treated as
137
+ * "not ready yet" and retried; a non-ok, non-404 response throws.
138
+ */
139
+ async pollVerdict(pending) {
140
+ let attempt = 0;
141
+ let waitMs = Math.max(250, pending.eta_ms || 1e3);
142
+ let last = pending;
143
+ const url = `${this.baseUrl}/v1/verdict/${encodeURIComponent(pending.job_id)}`;
144
+ while (attempt < this.maxPollAttempts) {
145
+ await sleep(Math.min(waitMs, 5e3));
146
+ const res = await this.fetchWithRetry(url, {
147
+ method: "GET",
148
+ headers: this.headers()
149
+ });
150
+ if (res.status === 404) {
151
+ attempt += 1;
152
+ waitMs = Math.min(waitMs * 2, 5e3);
153
+ continue;
154
+ }
155
+ if (!res.ok) {
156
+ throw new Error(`Verdict API GET /v1/verdict/${pending.job_id} failed: ${res.status} ${res.statusText}`);
157
+ }
158
+ last = await res.json();
159
+ if (!isPending(last))
160
+ return last;
161
+ attempt += 1;
162
+ waitMs = Math.min(waitMs * 2, 5e3);
163
+ }
164
+ return last;
165
+ }
166
+ /**
167
+ * Resolve a single item to a final envelope, polling through PENDING. Returns
168
+ * the envelope, or the unresolved PendingResponse if it never landed.
169
+ */
170
+ async resolveVerdict(item) {
171
+ const { results } = await this.getVerdicts([item]);
172
+ const first = results[0];
173
+ if (!first)
174
+ throw new Error("Verdict API returned no results for the request.");
175
+ if (isPending(first))
176
+ return this.pollVerdict(first);
177
+ return first;
178
+ }
179
+ /** Resolve a batch, polling each PENDING to resolution. Order matches input. */
180
+ async resolveVerdicts(items) {
181
+ const { results } = await this.getVerdicts(items);
182
+ return Promise.all(results.map((r) => isPending(r) ? this.pollVerdict(r) : Promise.resolve(r)));
183
+ }
184
+ /**
185
+ * High-level fail-open resolution (the CLI scan path). Returns one
186
+ * ResolvedItem per requested item. On a total API failure (network/timeout),
187
+ * falls back to the fail-open cache for every item; for individual
188
+ * missing/never-resolved items, falls back to the cache too. Items the cache
189
+ * cannot safely satisfy surface as 'unresolved' (UNKNOWN) so the operator
190
+ * decides — never a silent allow.
191
+ */
192
+ async resolve(items) {
193
+ if (items.length === 0)
194
+ return { resolved: [], degraded: false };
195
+ let response;
196
+ try {
197
+ response = await this.getVerdicts(items);
198
+ } catch {
199
+ return this.failOpen(items);
200
+ }
201
+ const results = response.results ?? [];
202
+ const resolved = [];
203
+ for (let i = 0; i < items.length; i++) {
204
+ const item = items[i];
205
+ const r = results[i];
206
+ if (!r) {
207
+ resolved.push(this.fromCacheOrUnresolved(item));
208
+ continue;
209
+ }
210
+ if (isPending(r)) {
211
+ let env;
212
+ try {
213
+ env = await this.pollVerdict(r);
214
+ } catch {
215
+ env = void 0;
216
+ }
217
+ if (env && isEnvelope(env)) {
218
+ this.cache?.put(item, env);
219
+ resolved.push({ item, envelope: env, source: "api" });
220
+ } else {
221
+ resolved.push(this.fromCacheOrUnresolved(item));
222
+ }
223
+ continue;
224
+ }
225
+ this.cache?.put(item, r);
226
+ resolved.push({ item, envelope: r, source: "api" });
227
+ }
228
+ return { resolved, degraded: false };
229
+ }
230
+ failOpen(items) {
231
+ const resolved = items.map((item) => {
232
+ const cached = this.cache?.get(item);
233
+ if (cached) {
234
+ return { item, envelope: cached, source: "cache", failedOpen: true };
235
+ }
236
+ return { item, source: "unresolved", failedOpen: true };
237
+ });
238
+ return { resolved, degraded: true };
239
+ }
240
+ fromCacheOrUnresolved(item) {
241
+ const cached = this.cache?.get(item);
242
+ if (cached)
243
+ return { item, envelope: cached, source: "cache" };
244
+ return { item, source: "unresolved" };
245
+ }
246
+ };
247
+
248
+ // src/config.ts
249
+ var DEFAULT_PROXY_URL = "https://proxy.inner.dev";
250
+ function stripTrailingSlash2(u) {
251
+ return u.replace(/\/+$/, "");
252
+ }
253
+ function resolveApiUrl(flagValue) {
254
+ const fromFlag = flagValue?.trim();
255
+ const fromEnv = process.env.INNER_API_URL?.trim();
256
+ const chosen = fromFlag || fromEnv || DEFAULT_API_URL;
257
+ return { url: stripTrailingSlash2(chosen), isDefault: !fromFlag && !fromEnv };
258
+ }
259
+ function resolveProxyUrl(flagValue) {
260
+ const fromFlag = flagValue?.trim();
261
+ const fromEnv = process.env.INNER_PROXY_URL?.trim();
262
+ const chosen = fromFlag || fromEnv || DEFAULT_PROXY_URL;
263
+ return { url: stripTrailingSlash2(chosen), isDefault: !fromFlag && !fromEnv };
264
+ }
265
+ function resolveConfig(opts = {}) {
266
+ const api = resolveApiUrl(opts.apiUrl);
267
+ const proxy = resolveProxyUrl(opts.proxyUrl);
268
+ return {
269
+ apiUrl: api.url,
270
+ proxyUrl: proxy.url,
271
+ apiUrlIsDefault: api.isDefault,
272
+ proxyUrlIsDefault: proxy.isDefault
273
+ };
274
+ }
275
+
276
+ // src/manifest.ts
277
+ import { existsSync, readFileSync } from "fs";
278
+ import { join } from "path";
279
+ function uniqueItems(items) {
280
+ const seen = /* @__PURE__ */ new Set();
281
+ const out = [];
282
+ for (const it of items) {
283
+ const k = `${it.ecosystem}:${it.name}:${it.version}`;
284
+ if (seen.has(k)) continue;
285
+ seen.add(k);
286
+ out.push(it);
287
+ }
288
+ return out;
289
+ }
290
+ function cleanVersion(raw) {
291
+ const trimmed = raw.trim();
292
+ if (!trimmed) return null;
293
+ if (trimmed === "*" || trimmed === "latest" || trimmed === "x" || trimmed === "X") return null;
294
+ const cleaned = trimmed.replace(/^[\^~>=<\s]+/, "").replace(/\s.*$/, "").replace(/^v/, "").trim();
295
+ if (!/^\d/.test(cleaned)) return null;
296
+ return cleaned;
297
+ }
298
+ function readNpmFromPackageJson(root) {
299
+ const p = join(root, "package.json");
300
+ if (!existsSync(p)) return { items: [], warnings: [] };
301
+ const items = [];
302
+ const warnings = [];
303
+ try {
304
+ const pkg = JSON.parse(readFileSync(p, "utf8"));
305
+ for (const block of [pkg.dependencies, pkg.devDependencies, pkg.optionalDependencies]) {
306
+ if (!block) continue;
307
+ for (const [name, spec] of Object.entries(block)) {
308
+ if (/^(workspace:|link:|file:|git\+|git:|github:|https?:)/.test(spec)) {
309
+ warnings.push(`package.json: skipped "${name}" (${spec}) \u2014 not registry-resolvable.`);
310
+ continue;
311
+ }
312
+ const alias = spec.match(/^npm:(@?[^@]+(?:\/[^@]+)?)@(.+)$/);
313
+ if (alias) {
314
+ const aliasName = alias[1];
315
+ const aliasVersion = cleanVersion(alias[2]);
316
+ if (!aliasVersion) {
317
+ warnings.push(`package.json: skipped "${name}" (${spec}) \u2014 alias has no concrete version.`);
318
+ continue;
319
+ }
320
+ items.push({ ecosystem: "npm", name: aliasName, version: aliasVersion });
321
+ continue;
322
+ }
323
+ const version = cleanVersion(spec);
324
+ if (!version) {
325
+ warnings.push(`package.json: skipped "${name}" (${spec}) \u2014 no concrete version (wildcard/tag/range).`);
326
+ continue;
327
+ }
328
+ items.push({ ecosystem: "npm", name, version });
329
+ }
330
+ }
331
+ } catch {
332
+ }
333
+ return { items, warnings };
334
+ }
335
+ function readNpmFromPackageLock(root) {
336
+ const p = join(root, "package-lock.json");
337
+ if (!existsSync(p)) return { items: [], warnings: [] };
338
+ const items = [];
339
+ try {
340
+ const lock = JSON.parse(readFileSync(p, "utf8"));
341
+ if (lock.packages) {
342
+ for (const [path, meta] of Object.entries(lock.packages)) {
343
+ if (!path.startsWith("node_modules/")) continue;
344
+ const name = path.slice(path.lastIndexOf("node_modules/") + "node_modules/".length);
345
+ if (!meta.version) continue;
346
+ items.push({ ecosystem: "npm", name, version: meta.version });
347
+ }
348
+ }
349
+ if (lock.dependencies && items.length === 0) {
350
+ for (const [name, meta] of Object.entries(lock.dependencies)) {
351
+ if (!meta.version) continue;
352
+ items.push({ ecosystem: "npm", name, version: meta.version });
353
+ }
354
+ }
355
+ } catch {
356
+ }
357
+ return { items, warnings: [] };
358
+ }
359
+ function readNpmFromYarnLock(root) {
360
+ const p = join(root, "yarn.lock");
361
+ if (!existsSync(p)) return { items: [], warnings: [] };
362
+ const items = [];
363
+ try {
364
+ const text = readFileSync(p, "utf8");
365
+ const lines = text.split(/\r?\n/);
366
+ let pendingNames = [];
367
+ for (const line of lines) {
368
+ if (/^[^\s].*:\s*$/.test(line) && !line.startsWith("#")) {
369
+ pendingNames = parseYarnHeaderNames(line.replace(/:\s*$/, ""));
370
+ continue;
371
+ }
372
+ const m = line.match(/^\s+version:?\s+"?([^"\s]+)"?/);
373
+ if (m && pendingNames.length) {
374
+ const version = m[1];
375
+ for (const name of pendingNames) items.push({ ecosystem: "npm", name, version });
376
+ pendingNames = [];
377
+ }
378
+ }
379
+ } catch {
380
+ }
381
+ return { items, warnings: [] };
382
+ }
383
+ function parseYarnHeaderNames(header) {
384
+ const names = /* @__PURE__ */ new Set();
385
+ for (let part of header.split(",")) {
386
+ part = part.trim().replace(/^"|"$/g, "");
387
+ if (!part) continue;
388
+ const at = part.lastIndexOf("@");
389
+ if (at <= 0) continue;
390
+ names.add(part.slice(0, at));
391
+ }
392
+ return [...names];
393
+ }
394
+ function readNpmFromPnpmLock(root) {
395
+ const p = join(root, "pnpm-lock.yaml");
396
+ if (!existsSync(p)) return { items: [], warnings: [] };
397
+ const items = [];
398
+ try {
399
+ const text = readFileSync(p, "utf8");
400
+ const re = /^\s*['"]?\/?((?:@[^/@\s]+\/)?[^/@\s'"]+)@(\d[^()'":\s]*)(?:\([^)]*\))*['"]?\s*:/gm;
401
+ let m;
402
+ while ((m = re.exec(text)) !== null) {
403
+ items.push({ ecosystem: "npm", name: m[1], version: m[2] });
404
+ }
405
+ } catch {
406
+ }
407
+ return { items, warnings: [] };
408
+ }
409
+ function readPypiFromRequirements(root) {
410
+ const items = [];
411
+ const warnings = [];
412
+ for (const file of ["requirements.txt"]) {
413
+ const p = join(root, file);
414
+ if (!existsSync(p)) continue;
415
+ try {
416
+ const text = readFileSync(p, "utf8");
417
+ for (let line of text.split(/\r?\n/)) {
418
+ line = line.replace(/\s+#.*$/, "").trim();
419
+ if (!line || line.startsWith("#")) continue;
420
+ if (line.startsWith("-") || line.includes("://")) {
421
+ warnings.push(`requirements.txt: skipped "${line.slice(0, 60)}" \u2014 not a simple pin.`);
422
+ continue;
423
+ }
424
+ const m = line.match(/^([A-Za-z0-9._-]+)(?:\[[^\]]*\])?\s*([=<>!~]=?)\s*([0-9][^\s;#]*)/);
425
+ if (m) {
426
+ items.push({ ecosystem: "pypi", name: m[1], version: m[3] });
427
+ } else {
428
+ warnings.push(`requirements.txt: skipped "${line.slice(0, 60)}" \u2014 no concrete version.`);
429
+ }
430
+ }
431
+ } catch {
432
+ }
433
+ }
434
+ return { items, warnings };
435
+ }
436
+ function readPypiFromPoetryLock(root) {
437
+ const p = join(root, "poetry.lock");
438
+ if (!existsSync(p)) return { items: [], warnings: [] };
439
+ const items = [];
440
+ try {
441
+ const text = readFileSync(p, "utf8");
442
+ const blocks = text.split(/\[\[package\]\]/);
443
+ for (const block of blocks) {
444
+ const name = block.match(/^\s*name\s*=\s*"([^"]+)"/m)?.[1];
445
+ const version = block.match(/^\s*version\s*=\s*"([^"]+)"/m)?.[1];
446
+ if (name && version) items.push({ ecosystem: "pypi", name, version });
447
+ }
448
+ } catch {
449
+ }
450
+ return { items, warnings: [] };
451
+ }
452
+ function readPypiFromUvLock(root) {
453
+ const p = join(root, "uv.lock");
454
+ if (!existsSync(p)) return { items: [], warnings: [] };
455
+ const items = [];
456
+ try {
457
+ const text = readFileSync(p, "utf8");
458
+ const blocks = text.split(/\[\[package\]\]/);
459
+ for (const block of blocks) {
460
+ const name = block.match(/^\s*name\s*=\s*"([^"]+)"/m)?.[1];
461
+ const version = block.match(/^\s*version\s*=\s*"([^"]+)"/m)?.[1];
462
+ if (name && version) items.push({ ecosystem: "pypi", name, version });
463
+ }
464
+ } catch {
465
+ }
466
+ return { items, warnings: [] };
467
+ }
468
+ function readPypiFromPyproject(root) {
469
+ const p = join(root, "pyproject.toml");
470
+ if (!existsSync(p)) return { items: [], warnings: [] };
471
+ const items = [];
472
+ const warnings = [];
473
+ try {
474
+ const text = readFileSync(p, "utf8");
475
+ const arr = text.match(/dependencies\s*=\s*\[([\s\S]*?)\]/);
476
+ if (arr) {
477
+ for (const m of arr[1].matchAll(/"([^"]+)"/g)) {
478
+ const spec = m[1];
479
+ const dm = spec.match(/^([A-Za-z0-9._-]+)(?:\[[^\]]*\])?\s*([=<>!~]=?)\s*([0-9][^\s;]*)/);
480
+ if (dm) items.push({ ecosystem: "pypi", name: dm[1], version: dm[3] });
481
+ else warnings.push(`pyproject.toml: skipped "${spec.slice(0, 60)}" \u2014 no concrete version.`);
482
+ }
483
+ }
484
+ } catch {
485
+ }
486
+ return { items, warnings };
487
+ }
488
+ function scanManifests(root = process.cwd()) {
489
+ const items = [];
490
+ const sources = [];
491
+ const ecosystems = /* @__PURE__ */ new Set();
492
+ const warnings = [];
493
+ const npmReaders = [
494
+ ["package-lock.json", readNpmFromPackageLock],
495
+ ["pnpm-lock.yaml", readNpmFromPnpmLock],
496
+ ["yarn.lock", readNpmFromYarnLock],
497
+ ["package.json", readNpmFromPackageJson]
498
+ ];
499
+ for (const [file, reader] of npmReaders) {
500
+ if (!existsSync(join(root, file))) continue;
501
+ const got = reader(root);
502
+ if (got.items.length) {
503
+ items.push(...got.items);
504
+ warnings.push(...got.warnings);
505
+ sources.push(file);
506
+ ecosystems.add("npm");
507
+ break;
508
+ }
509
+ }
510
+ const pypiReaders = [
511
+ ["poetry.lock", readPypiFromPoetryLock],
512
+ ["uv.lock", readPypiFromUvLock],
513
+ ["requirements.txt", readPypiFromRequirements],
514
+ ["pyproject.toml", readPypiFromPyproject]
515
+ ];
516
+ for (const [file, reader] of pypiReaders) {
517
+ if (!existsSync(join(root, file))) continue;
518
+ const got = reader(root);
519
+ if (got.items.length) {
520
+ items.push(...got.items);
521
+ warnings.push(...got.warnings);
522
+ sources.push(file);
523
+ ecosystems.add("pypi");
524
+ break;
525
+ }
526
+ }
527
+ return {
528
+ items: uniqueItems(items),
529
+ sources,
530
+ ecosystems: [...ecosystems],
531
+ warnings,
532
+ skipped: warnings.length
533
+ };
534
+ }
535
+
536
+ // src/cache.ts
537
+ import { mkdirSync, readFileSync as readFileSync2, writeFileSync, existsSync as existsSync2 } from "fs";
538
+ import { join as join2 } from "path";
539
+ var CACHE_DIR_NAME = ".inner-cache";
540
+ var CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1e3;
541
+ function cacheKey(item) {
542
+ return `${item.ecosystem}:${item.name}:${item.version}`;
543
+ }
544
+ function keyToFilename(key) {
545
+ return key.replace(/[^a-zA-Z0-9._-]/g, "_") + ".json";
546
+ }
547
+ var VerdictCache = class {
548
+ dir;
549
+ constructor(rootDir = process.cwd()) {
550
+ this.dir = join2(rootDir, CACHE_DIR_NAME);
551
+ }
552
+ pathFor(key) {
553
+ return join2(this.dir, keyToFilename(key));
554
+ }
555
+ /** Persist a resolved envelope as last-known-good. */
556
+ put(item, envelope) {
557
+ try {
558
+ if (!existsSync2(this.dir)) mkdirSync(this.dir, { recursive: true });
559
+ const entry = {
560
+ key: cacheKey(item),
561
+ cached_at: (/* @__PURE__ */ new Date()).toISOString(),
562
+ envelope
563
+ };
564
+ writeFileSync(this.pathFor(entry.key), JSON.stringify(entry, null, 2), "utf8");
565
+ } catch {
566
+ }
567
+ }
568
+ /**
569
+ * Read a last-known-good envelope that is safe to TRUST for resolution, or
570
+ * undefined on miss/parse error/untrusted entry.
571
+ *
572
+ * The cache is fail-OPEN: only a cached ALLOW that has not gone stale is a
573
+ * usable verdict. A cached BLOCK/REVIEW (or anything non-ALLOW), or an
574
+ * expired entry, is treated as a miss so the caller surfaces UNKNOWN and the
575
+ * operator decides — never a silent allow of a now-known-bad package.
576
+ */
577
+ get(item, now = /* @__PURE__ */ new Date()) {
578
+ try {
579
+ const p = this.pathFor(cacheKey(item));
580
+ if (!existsSync2(p)) return void 0;
581
+ const entry = JSON.parse(readFileSync2(p, "utf8"));
582
+ const envelope = entry.envelope;
583
+ if (!envelope || envelope.action !== "ALLOW") return void 0;
584
+ if (isStale(entry, now)) return void 0;
585
+ return envelope;
586
+ } catch {
587
+ return void 0;
588
+ }
589
+ }
590
+ };
591
+ function isStale(entry, now) {
592
+ const nowMs = now.getTime();
593
+ const expiresMs = Date.parse(entry.envelope?.expires_at ?? "");
594
+ if (!Number.isNaN(expiresMs)) return expiresMs <= nowMs;
595
+ const cachedMs = Date.parse(entry.cached_at ?? "");
596
+ if (!Number.isNaN(cachedMs)) return nowMs - cachedMs >= CACHE_MAX_AGE_MS;
597
+ return true;
598
+ }
599
+
600
+ // src/client.ts
601
+ var VerdictClient2 = class {
602
+ client;
603
+ constructor(opts) {
604
+ const cache = opts.noCache ? void 0 : new VerdictCache(opts.cacheRoot);
605
+ this.client = new VerdictClient({
606
+ baseUrl: opts.apiUrl,
607
+ timeoutMs: opts.timeoutMs,
608
+ maxPollAttempts: opts.maxPollAttempts ?? 5,
609
+ // The CLI scan gate fails open FAST: no transient-retry before serving the
610
+ // last-known-good cache (CR-E1.1). A build must not hang on a slow/hung
611
+ // Verdict API. The shared client keeps retries for the MCP path, which has
612
+ // no build-latency contract and benefits from riding transient blips.
613
+ maxRetries: 0,
614
+ cache: cache ? {
615
+ get: (item) => cache.get(item),
616
+ put: (item, env) => cache.put(item, env)
617
+ } : void 0
618
+ });
619
+ }
620
+ /**
621
+ * Resolve a batch of items. Returns one ResolvedItem per requested item.
622
+ * On total API failure, falls back to the fail-open cache for every item.
623
+ */
624
+ resolve(items) {
625
+ return this.client.resolve(items);
626
+ }
627
+ };
628
+
629
+ // src/scan.ts
630
+ function aggregate(resolved) {
631
+ const tally = {
632
+ scanned: resolved.length,
633
+ allowed: 0,
634
+ blocked: 0,
635
+ review: 0,
636
+ unknown: 0,
637
+ fromCache: 0
638
+ };
639
+ for (const r of resolved) {
640
+ if (r.source === "cache") tally.fromCache++;
641
+ if (!r.envelope) {
642
+ tally.unknown++;
643
+ continue;
644
+ }
645
+ const action = r.envelope.action;
646
+ switch (action) {
647
+ case "ALLOW":
648
+ tally.allowed++;
649
+ break;
650
+ case "BLOCK":
651
+ tally.blocked++;
652
+ break;
653
+ case "REVIEW":
654
+ tally.review++;
655
+ break;
656
+ default:
657
+ tally.unknown++;
658
+ }
659
+ }
660
+ return tally;
661
+ }
662
+ async function scan(opts = {}) {
663
+ const root = opts.root ?? process.cwd();
664
+ const cfg = resolveConfig({ apiUrl: opts.apiUrl });
665
+ const manifest = opts.manifest ?? scanManifests(root);
666
+ const client = new VerdictClient2({
667
+ apiUrl: cfg.apiUrl,
668
+ cacheRoot: root,
669
+ noCache: opts.noCache
670
+ });
671
+ const { resolved, degraded } = await client.resolve(manifest.items);
672
+ return {
673
+ tally: aggregate(resolved),
674
+ resolved,
675
+ sources: manifest.sources,
676
+ ecosystems: manifest.ecosystems,
677
+ degraded,
678
+ apiUrl: cfg.apiUrl,
679
+ apiUrlIsDefault: cfg.apiUrlIsDefault,
680
+ skipped: manifest.skipped,
681
+ warnings: manifest.warnings
682
+ };
683
+ }
684
+ function formatTally(t) {
685
+ return `${t.scanned} packages scanned, ${t.allowed} allowed, ${t.blocked} blocked`;
686
+ }
687
+
688
+ // src/scan-render.ts
689
+ async function runScanCommand(opts) {
690
+ const result = await scan(opts);
691
+ if (opts.json) return writeJson(result);
692
+ return writeText(result);
693
+ }
694
+ function writeJson(result) {
695
+ process.stdout.write(
696
+ JSON.stringify(
697
+ {
698
+ tally: result.tally,
699
+ ecosystems: result.ecosystems,
700
+ sources: result.sources,
701
+ degraded: result.degraded,
702
+ apiUrl: result.apiUrl,
703
+ apiUrlIsDefault: result.apiUrlIsDefault,
704
+ skipped: result.skipped,
705
+ warnings: result.warnings,
706
+ items: result.resolved.map((r) => ({
707
+ ecosystem: r.item.ecosystem,
708
+ name: r.item.name,
709
+ version: r.item.version,
710
+ action: r.envelope?.action ?? "UNKNOWN",
711
+ status: r.envelope?.status,
712
+ source: r.source
713
+ }))
714
+ },
715
+ null,
716
+ 2
717
+ ) + "\n"
718
+ );
719
+ return result.tally.blocked > 0 ? 1 : 0;
720
+ }
721
+ function writeText(result) {
722
+ const t = result.tally;
723
+ if (t.scanned === 0) {
724
+ process.stdout.write(
725
+ "No package manifest/lockfile found. Run inside a project with a package.json / requirements.txt / lockfile.\n"
726
+ );
727
+ return 0;
728
+ }
729
+ process.stdout.write("Inner scan \u2014 " + (result.ecosystems.join(", ") || "no ecosystem") + "\n");
730
+ if (result.sources.length) {
731
+ process.stdout.write(" sources: " + result.sources.join(", ") + "\n");
732
+ }
733
+ process.stdout.write(" " + formatTally(t) + "\n");
734
+ if (t.review) process.stdout.write(` ${t.review} need review
735
+ `);
736
+ if (t.unknown) process.stdout.write(` ${t.unknown} unknown / unresolved
737
+ `);
738
+ if (result.skipped) {
739
+ process.stdout.write(
740
+ ` \u26A0 ${result.skipped} manifest entr${result.skipped === 1 ? "y" : "ies"} skipped (unparseable \u2014 not scanned):
741
+ `
742
+ );
743
+ for (const w of result.warnings) process.stdout.write(` - ${w}
744
+ `);
745
+ }
746
+ if (result.degraded) {
747
+ process.stdout.write(
748
+ ` (API unreachable \u2014 ${t.fromCache} resolved from fail-open cache, others marked unknown)
749
+ `
750
+ );
751
+ }
752
+ if (result.apiUrlIsDefault) {
753
+ process.stdout.write(
754
+ ` note: using default Verdict API ${result.apiUrl} \u2014 set INNER_API_URL to the production endpoint.
755
+ `
756
+ );
757
+ }
758
+ const blocked = result.resolved.filter((r) => r.envelope?.action === "BLOCK");
759
+ if (blocked.length) {
760
+ process.stdout.write("\nBLOCKED:\n");
761
+ for (const b of blocked) {
762
+ process.stdout.write(
763
+ ` \u2717 ${b.item.ecosystem}:${b.item.name}@${b.item.version}` + (b.envelope?.status ? ` (${b.envelope.status})` : "") + "\n"
764
+ );
765
+ }
766
+ }
767
+ return t.blocked > 0 ? 1 : 0;
768
+ }
769
+
770
+ // src/scan-cli.ts
771
+ var HELP = `inner scan \u2014 zero-auth instant dependency scan (consumes Inner Verdict API / C1)
772
+
773
+ Usage:
774
+ npx @inner-security/scan [options]
775
+
776
+ Options:
777
+ --dir <path> Project root to scan (default: cwd)
778
+ --api-url <url> Verdict API base URL (default: $INNER_API_URL or local mock)
779
+ --json Emit machine-readable JSON instead of the text tally
780
+ --no-cache Do not read/write the fail-open .inner-cache/
781
+ -h, --help Show this help
782
+
783
+ Env:
784
+ INNER_API_URL Verdict API base URL (overridden by --api-url)
785
+
786
+ Exit codes:
787
+ 0 no blocked packages
788
+ 1 one or more BLOCKED packages found
789
+ 2 usage / unexpected error
790
+ `;
791
+ async function main() {
792
+ const { flags } = parseArgs(process.argv.slice(2));
793
+ if (flagBool(flags, "help")) {
794
+ process.stdout.write(HELP);
795
+ return 0;
796
+ }
797
+ return runScanCommand({
798
+ root: flagString(flags, "dir"),
799
+ apiUrl: flagString(flags, "api-url"),
800
+ noCache: flagBool(flags, "no-cache"),
801
+ json: flagBool(flags, "json")
802
+ });
803
+ }
804
+ main().then((code) => process.exit(code)).catch((err) => {
805
+ process.stderr.write(`inner scan: ${err instanceof Error ? err.message : String(err)}
806
+ `);
807
+ process.exit(2);
808
+ });