border-customs 0.3.0 → 0.3.2
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 +82 -1
- package/dist/assets/package-lock.json +4 -11
- package/dist/index.js +372 -23
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -313,6 +313,7 @@ border <command> [options]
|
|
|
313
313
|
| `status` | newest gate records per target |
|
|
314
314
|
| `llm-request` | emit the masked review bundle for LLM-authored commits |
|
|
315
315
|
| `llm-ingest <findings.json>` | validate agent findings and record the combined verdict |
|
|
316
|
+
| `scan <[ecosystem:]name@version>` | inspect a third-party published artifact for residue before installing it |
|
|
316
317
|
|
|
317
318
|
Every subcommand accepts the same global flags (verified against `border <cmd> --help`):
|
|
318
319
|
|
|
@@ -324,7 +325,7 @@ Every subcommand accepts the same global flags (verified against `border <cmd> -
|
|
|
324
325
|
| `--yes` | execute mutations (push only); without it a push is always DRY-RUN |
|
|
325
326
|
| `--require-engine <list>` | replaces the required-engine set from config; unknown or unprobeable names degrade the run (exit 2) |
|
|
326
327
|
| `--llm` | opt this check into the LLM review layer (a plain check can never satisfy an llm-recorded skip) |
|
|
327
|
-
| `--json` | machine-readable report on stdout (check) |
|
|
328
|
+
| `--json` | machine-readable report on stdout (check, scan) |
|
|
328
329
|
| `--help, -h` | usage table |
|
|
329
330
|
|
|
330
331
|
### Exit codes (the contract)
|
|
@@ -340,6 +341,73 @@ rests on a leg that silently skipped. Engine exit codes are translated against a
|
|
|
340
341
|
matrix (`gitleaks` 0/1, `trufflehog` 0/183, `secretlint` 0/1); *anything* else, including a
|
|
341
342
|
126 from gitleaks, is exit 2, never "clean".
|
|
342
343
|
|
|
344
|
+
### `border scan` — inspect a third-party package before installing it
|
|
345
|
+
|
|
346
|
+
```bash
|
|
347
|
+
border scan puppeteer@23.11.1 # npm is the default ecosystem
|
|
348
|
+
border scan pypi:requests@2.32.3
|
|
349
|
+
border scan crates:serde@1.0.219
|
|
350
|
+
border scan rubygems:rails@7.1.1
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
`border scan <[ecosystem:]name@version>` fetches that exact published artifact from its
|
|
354
|
+
registry, materializes it in a throwaway temp directory, and runs the same engine stack
|
|
355
|
+
`check` runs — secrets plus the T0–T4 residue classifiers — over the bytes you are about
|
|
356
|
+
to install. `--json` emits the standard machine-readable report.
|
|
357
|
+
|
|
358
|
+
**Capability, not fact.** A scan verdict proves what the package *can* do — persist past
|
|
359
|
+
uninstall, write outside the install tree, run hooks on every consumer install — because
|
|
360
|
+
those shapes are readable in the published bytes. Proof that a package *did* persist is
|
|
361
|
+
the uninstall-roundtrip valve, and it ships in 0.4.0; until then treat a scan as pre-install
|
|
362
|
+
risk triage, never as post-mortem evidence.
|
|
363
|
+
|
|
364
|
+
Scan touches neither side of your work: it reads no ledger and writes no skip records (every
|
|
365
|
+
scan is a full scan), and it creates no `.border/` in your repo — the fetched artifact and
|
|
366
|
+
its temp git tree live in the sandbox and are destroyed when the run ends. Exit codes follow
|
|
367
|
+
the same contract: `0` clean, `1` blocking (HIGH/CRITICAL) findings, `2` the scan could not
|
|
368
|
+
genuinely run — unreachable registry, malformed spec, a tree shape the engines cannot
|
|
369
|
+
consume. A scan that could not run never prints a clean verdict.
|
|
370
|
+
|
|
371
|
+
crates artifacts get one **envelope normalization** before staging: a registry `.crate`
|
|
372
|
+
ships three files — `Cargo.toml.orig`, `.cargo-ok`, `.cargo_vcs_info.json` — that
|
|
373
|
+
`cargo package` rejects as reserved, so scan drops them before the rebuild and logs each
|
|
374
|
+
dropped file to stderr with the sha256 of its original bytes
|
|
375
|
+
(`border scan: crates envelope normalized: <file> sha256=<hex>`); the rebuild regenerates
|
|
376
|
+
them from the normalized manifest, so no scanned byte is silently lost.
|
|
377
|
+
|
|
378
|
+
### Engines in consumer installs
|
|
379
|
+
|
|
380
|
+
`dist/index.js` is one self-contained esbuild bundle and every runtime asset (vendored
|
|
381
|
+
gitleaks rules, prompt template, the secretlint fingerprint snapshot) ships inside `dist/`,
|
|
382
|
+
so after `npm i -D border-customs` (or `-g`) the engine surface is:
|
|
383
|
+
|
|
384
|
+
- **publint** — installed automatically: it is an exact-pinned runtime dependency of the
|
|
385
|
+
package, invoked via bin resolution (package-local `node_modules/.bin`, the hoisted
|
|
386
|
+
project-level `node_modules/.bin`, then `PATH`, then `~/.local/bin`). The
|
|
387
|
+
`publint-fail` leg of the npm artifact stage works out of the box in a consumer install.
|
|
388
|
+
- **secretlint** — no consumer action, ever: the default lint runs **in-process** with the
|
|
389
|
+
`@secretlint/*` rule modules already declared as border's own runtime dependencies
|
|
390
|
+
(`src/engines/secretlint.ts` header). The `mode: "cli"` fallback that spawns a
|
|
391
|
+
`secretlint` binary is border-internal (test transport); it is not reachable from
|
|
392
|
+
`border.yaml` or any flag, so installing `secretlint` into your project changes nothing
|
|
393
|
+
for border. If the engine probe cannot read the bundled lock snapshot
|
|
394
|
+
(`dist/assets/package-lock.json`), the run fails closed as `DEGRADED-ENGINE` exit 2 —
|
|
395
|
+
never a silent pass.
|
|
396
|
+
- **gitleaks** — stays external, same posture as `git`: border vendors the 8.30.1 rule
|
|
397
|
+
config but the binary is yours (on `PATH` or `~/.local/bin/gitleaks`; see the
|
|
398
|
+
requirements paragraph at the top and `src/engines/ADAPTER-CONTRACT.md`). Missing
|
|
399
|
+
gitleaks means `border check` records a CRITICAL `DEGRADED-ENGINE` finding and exits 2,
|
|
400
|
+
and `border scan` exits 2 with `engine binary 'gitleaks' not found; border fails
|
|
401
|
+
closed`. Neither command degrades to a skip.
|
|
402
|
+
- **trufflehog** — optional third engine, external binary, same posture as gitleaks once
|
|
403
|
+
enabled: `engines.trufflehog: true` puts it in the required set, and an absent binary
|
|
404
|
+
degrades the run to exit 2 exactly like a missing gitleaks.
|
|
405
|
+
|
|
406
|
+
There is no "engine absent ⇒ leg silently skipped" state anywhere: missing required
|
|
407
|
+
engines are exit 2 (`2` = the gate could not answer, see
|
|
408
|
+
[Exit codes](#exit-codes-the-contract)), and the ledger refuses to write a PASS for a
|
|
409
|
+
degraded run.
|
|
410
|
+
|
|
343
411
|
## Configuration
|
|
344
412
|
|
|
345
413
|
Everything border can be told lives in `border.yaml` at the repo root. Unknown keys are
|
|
@@ -506,6 +574,19 @@ same one that wrote the commit) how to run the five subcommands, how to produce
|
|
|
506
574
|
|
|
507
575
|
## Changelog
|
|
508
576
|
|
|
577
|
+
### 0.3.2 (2026-09-10)
|
|
578
|
+
- Fix: CLI invoked through the npm `.bin` shim silently exited 0 without running
|
|
579
|
+
(entrypoint detection now realpath-canonicalizes both sides). Regression-locked by a
|
|
580
|
+
pack -> install -> shim-invocation test (`BORDER_PACK_TEST=1`, wired into CI).
|
|
581
|
+
- Fix: `publint` is an exact-pinned runtime dependency, so `border scan`/npm checks work
|
|
582
|
+
out of the box in consumer installs; README documents the engine posture truthfully.
|
|
583
|
+
- Add: release-coherence seed test pinning the scan User-Agent version to package.json.
|
|
584
|
+
|
|
585
|
+
### 0.3.1 (2026-09-10)
|
|
586
|
+
- Add: `border scan [ecosystem:]name@version` — static residue inspection of third-party
|
|
587
|
+
registry packages (npm/PyPI/crates.io/RubyGems), ledger-free by design.
|
|
588
|
+
- Docs: README scan section; crates envelope normalization now logs scrubbed-file hashes.
|
|
589
|
+
|
|
509
590
|
### 0.3.0 (2026-09-09)
|
|
510
591
|
|
|
511
592
|
- **Residue gate (artifact stage).** The blanket install-hook CRITICAL becomes a classified
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "border-customs",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
|
-
"name": "border",
|
|
9
|
-
"version": "0.1
|
|
8
|
+
"name": "border-customs",
|
|
9
|
+
"version": "0.3.1",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@secretlint/core": "13.0.5",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"@secretlint/secretlint-rule-no-homedir": "13.0.5",
|
|
15
15
|
"@secretlint/secretlint-rule-pattern": "13.0.5",
|
|
16
16
|
"@secretlint/secretlint-rule-preset-recommend": "13.0.5",
|
|
17
|
+
"publint": "0.3.24",
|
|
17
18
|
"yaml": "2.9.0",
|
|
18
19
|
"zod": "4.5.4"
|
|
19
20
|
},
|
|
@@ -23,7 +24,6 @@
|
|
|
23
24
|
"devDependencies": {
|
|
24
25
|
"@types/node": "22.19.0",
|
|
25
26
|
"esbuild": "0.25.12",
|
|
26
|
-
"publint": "0.3.24",
|
|
27
27
|
"secretlint": "13.0.5",
|
|
28
28
|
"typescript": "5.9.3",
|
|
29
29
|
"verdaccio": "6.10.2"
|
|
@@ -601,7 +601,6 @@
|
|
|
601
601
|
"version": "0.1.7",
|
|
602
602
|
"resolved": "https://registry.npmjs.org/@publint/pack/-/pack-0.1.7.tgz",
|
|
603
603
|
"integrity": "sha512-4EDEmvxWtgsCnnVeBvtFIFZtUhPPt1+bA9JrSwU4Sa//6oKtzCSlGGXYJr44OD9aGISymbieJ4mCKHUygUDU+g==",
|
|
604
|
-
"dev": true,
|
|
605
604
|
"license": "MIT",
|
|
606
605
|
"dependencies": {
|
|
607
606
|
"tinyexec": "^1.3.0"
|
|
@@ -3821,7 +3820,6 @@
|
|
|
3821
3820
|
"version": "1.2.0",
|
|
3822
3821
|
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
|
3823
3822
|
"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
|
|
3824
|
-
"dev": true,
|
|
3825
3823
|
"license": "MIT",
|
|
3826
3824
|
"engines": {
|
|
3827
3825
|
"node": ">=4"
|
|
@@ -3982,7 +3980,6 @@
|
|
|
3982
3980
|
"version": "1.8.0",
|
|
3983
3981
|
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz",
|
|
3984
3982
|
"integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==",
|
|
3985
|
-
"dev": true,
|
|
3986
3983
|
"license": "MIT"
|
|
3987
3984
|
},
|
|
3988
3985
|
"node_modules/pako": {
|
|
@@ -4073,7 +4070,6 @@
|
|
|
4073
4070
|
"version": "1.1.1",
|
|
4074
4071
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
|
4075
4072
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
|
4076
|
-
"dev": true,
|
|
4077
4073
|
"license": "ISC"
|
|
4078
4074
|
},
|
|
4079
4075
|
"node_modules/picomatch": {
|
|
@@ -4255,7 +4251,6 @@
|
|
|
4255
4251
|
"version": "0.3.24",
|
|
4256
4252
|
"resolved": "https://registry.npmjs.org/publint/-/publint-0.3.24.tgz",
|
|
4257
4253
|
"integrity": "sha512-9zS56KrKBoqi5Qt8h92uMP8TTM9AYZSgnmCo4u2priMqkOZvQnTsziZ2p5LJ2ywbYkAjoCDp2jda9u4cgFefIw==",
|
|
4258
|
-
"dev": true,
|
|
4259
4254
|
"license": "MIT",
|
|
4260
4255
|
"dependencies": {
|
|
4261
4256
|
"@publint/pack": "^0.1.7",
|
|
@@ -4551,7 +4546,6 @@
|
|
|
4551
4546
|
"version": "1.8.1",
|
|
4552
4547
|
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
|
|
4553
4548
|
"integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
|
|
4554
|
-
"dev": true,
|
|
4555
4549
|
"license": "MIT",
|
|
4556
4550
|
"dependencies": {
|
|
4557
4551
|
"mri": "^1.1.0"
|
|
@@ -5231,7 +5225,6 @@
|
|
|
5231
5225
|
"version": "1.3.1",
|
|
5232
5226
|
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz",
|
|
5233
5227
|
"integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==",
|
|
5234
|
-
"dev": true,
|
|
5235
5228
|
"license": "MIT",
|
|
5236
5229
|
"engines": {
|
|
5237
5230
|
"node": ">=18"
|
package/dist/index.js
CHANGED
|
@@ -13028,7 +13028,8 @@ var require_micromatch = __commonJS({
|
|
|
13028
13028
|
});
|
|
13029
13029
|
|
|
13030
13030
|
// src/index.ts
|
|
13031
|
-
import {
|
|
13031
|
+
import { realpathSync } from "node:fs";
|
|
13032
|
+
import { fileURLToPath as fileURLToPath5, pathToFileURL } from "node:url";
|
|
13032
13033
|
|
|
13033
13034
|
// src/cli.ts
|
|
13034
13035
|
import { parseArgs } from "node:util";
|
|
@@ -37896,10 +37897,14 @@ var NPM_TARGET_LABEL = "artifact";
|
|
|
37896
37897
|
var DEFAULT_TOOL_TIMEOUT_MS = 12e4;
|
|
37897
37898
|
var MESSAGE_TAIL_CHARS = 600;
|
|
37898
37899
|
var HERE = dirname3(fileURLToPath3(import.meta.url));
|
|
37899
|
-
|
|
37900
|
-
|
|
37901
|
-
|
|
37902
|
-
|
|
37900
|
+
function publintLocalBins(here) {
|
|
37901
|
+
return [
|
|
37902
|
+
join6(here, "..", "node_modules", ".bin", "publint"),
|
|
37903
|
+
join6(here, "..", "..", "node_modules", ".bin", "publint"),
|
|
37904
|
+
join6(here, "..", "..", ".bin", "publint")
|
|
37905
|
+
];
|
|
37906
|
+
}
|
|
37907
|
+
var PUBLINT_LOCAL_BINS = publintLocalBins(HERE);
|
|
37903
37908
|
function tail(text) {
|
|
37904
37909
|
const trimmed = text.trim();
|
|
37905
37910
|
return trimmed.length > MESSAGE_TAIL_CHARS ? `${trimmed.slice(-MESSAGE_TAIL_CHARS)}\u2026` : trimmed;
|
|
@@ -40394,8 +40399,8 @@ function exitCodeFromVerdict(verdict) {
|
|
|
40394
40399
|
var MESSAGE_CAP = 512;
|
|
40395
40400
|
var URL_IN_TEXT_RE = /(?:\w+:\/\/\S+|[A-Za-z0-9_.+-]+@[A-Za-z0-9_.+-]+:\S+)/g;
|
|
40396
40401
|
function sanitizeMessage(message) {
|
|
40397
|
-
const
|
|
40398
|
-
const scrubbed =
|
|
40402
|
+
const oneLine2 = message.replace(/\s+/g, " ").trim();
|
|
40403
|
+
const scrubbed = oneLine2.replace(URL_IN_TEXT_RE, (candidate) => sanitizeUrl(candidate));
|
|
40399
40404
|
return scrubbed.length > MESSAGE_CAP ? `${scrubbed.slice(0, MESSAGE_CAP - 3)}...` : scrubbed;
|
|
40400
40405
|
}
|
|
40401
40406
|
function labelFor(err) {
|
|
@@ -40442,17 +40447,17 @@ function requirePassedRecord(kind, i) {
|
|
|
40442
40447
|
function rehashRecordedArtifacts(kind, extensions, record2, repoDir) {
|
|
40443
40448
|
const conventions = extensions;
|
|
40444
40449
|
const entries = (record2.artifacts ?? []).filter((a) => conventions.some((ext) => a.file.endsWith(ext)));
|
|
40445
|
-
const
|
|
40450
|
+
const fail3 = (detail) => ({ ok: false, message: `artifact changed since check \u2014 ${detail}` });
|
|
40446
40451
|
if (entries.length === 0) {
|
|
40447
|
-
return
|
|
40452
|
+
return fail3(`the PASSED check recorded no ${kind} artifacts (${conventions.join("/")}) to re-verify`);
|
|
40448
40453
|
}
|
|
40449
40454
|
const files = [];
|
|
40450
40455
|
for (const a of entries) {
|
|
40451
40456
|
const rel = join16(DIST_DIR, basename3(a.file));
|
|
40452
40457
|
const abs = join16(repoDir, rel);
|
|
40453
|
-
if (!existsSync11(abs)) return
|
|
40458
|
+
if (!existsSync11(abs)) return fail3(`${rel} is missing from the working tree`);
|
|
40454
40459
|
const digest2 = createHash10("sha256").update(readFileSync10(abs)).digest("hex");
|
|
40455
|
-
if (digest2 !== a.sha256) return
|
|
40460
|
+
if (digest2 !== a.sha256) return fail3(`${rel} no longer hashes to the digest recorded by the check`);
|
|
40456
40461
|
files.push(rel);
|
|
40457
40462
|
}
|
|
40458
40463
|
return { ok: true, files: files.sort() };
|
|
@@ -40911,7 +40916,7 @@ function npmExposureCoords(cfg, read) {
|
|
|
40911
40916
|
return { name, version: version2 };
|
|
40912
40917
|
}
|
|
40913
40918
|
function classifyNpmVersionView(o) {
|
|
40914
|
-
const
|
|
40919
|
+
const fail3 = (why) => {
|
|
40915
40920
|
throw new EngineRunError(
|
|
40916
40921
|
`npm registry probe inconclusive (${why}); refusing to assume the version is absent \u2014 registry unreachable or returned unexpected output, push blocked until the probe succeeds`,
|
|
40917
40922
|
o.status
|
|
@@ -40919,19 +40924,19 @@ function classifyNpmVersionView(o) {
|
|
|
40919
40924
|
};
|
|
40920
40925
|
if (o.status === 0) {
|
|
40921
40926
|
const text = o.stdout.trim();
|
|
40922
|
-
if (text.length === 0) return
|
|
40927
|
+
if (text.length === 0) return fail3("exit 0 with empty stdout");
|
|
40923
40928
|
let body;
|
|
40924
40929
|
try {
|
|
40925
40930
|
body = JSON.parse(text);
|
|
40926
40931
|
} catch {
|
|
40927
|
-
return
|
|
40932
|
+
return fail3("exit 0 with malformed JSON body");
|
|
40928
40933
|
}
|
|
40929
|
-
if (typeof body !== "object" || body === null) return
|
|
40934
|
+
if (typeof body !== "object" || body === null) return fail3("exit 0 with non-object JSON body");
|
|
40930
40935
|
return "present";
|
|
40931
40936
|
}
|
|
40932
40937
|
if (/code E404\b/.test(o.stderr) || /\b404 Not Found\b/.test(o.stderr)) return "absent";
|
|
40933
40938
|
const m = /code (E[A-Z0-9_]+|ERR_[A-Z0-9_]+)/.exec(o.stderr);
|
|
40934
|
-
return
|
|
40939
|
+
return fail3(m === null ? `exit ${String(o.status)} without a 404 marker` : `npm error ${m[1]}`);
|
|
40935
40940
|
}
|
|
40936
40941
|
function npmNameClaimed(o) {
|
|
40937
40942
|
if (o.status === 0) return true;
|
|
@@ -42024,6 +42029,10 @@ function expandInConfig(cfg, env2) {
|
|
|
42024
42029
|
}
|
|
42025
42030
|
return { ...cfg, targets };
|
|
42026
42031
|
}
|
|
42032
|
+
function parseConfig(text, source = "<inline>", options = {}) {
|
|
42033
|
+
const doc = yamlToJson(text, source);
|
|
42034
|
+
return expandInConfig(validateDoc(doc, source), options.env ?? process.env);
|
|
42035
|
+
}
|
|
42027
42036
|
function deepMergeRaw(base, overlay) {
|
|
42028
42037
|
if (isRecord(base) && isRecord(overlay)) {
|
|
42029
42038
|
const out = { ...base };
|
|
@@ -43427,6 +43436,336 @@ async function executeYesPush(ctx, loaded) {
|
|
|
43427
43436
|
return EXIT_PASS;
|
|
43428
43437
|
}
|
|
43429
43438
|
|
|
43439
|
+
// src/scan/scan.ts
|
|
43440
|
+
import { mkdtempSync as mkdtempSync5 } from "node:fs";
|
|
43441
|
+
import { tmpdir as tmpdir3 } from "node:os";
|
|
43442
|
+
import { join as join26 } from "node:path";
|
|
43443
|
+
|
|
43444
|
+
// src/scan/config.ts
|
|
43445
|
+
function buildScanConfigYaml(eco) {
|
|
43446
|
+
return [
|
|
43447
|
+
"version: 1",
|
|
43448
|
+
"targets:",
|
|
43449
|
+
" git:",
|
|
43450
|
+
" remotes: []",
|
|
43451
|
+
` ${eco}: {}`,
|
|
43452
|
+
"rules:",
|
|
43453
|
+
" authors:",
|
|
43454
|
+
" emails: []",
|
|
43455
|
+
" names: []",
|
|
43456
|
+
" hosts: []",
|
|
43457
|
+
" ips: []",
|
|
43458
|
+
" pathPatterns: []",
|
|
43459
|
+
"residue:",
|
|
43460
|
+
" enabled: true",
|
|
43461
|
+
""
|
|
43462
|
+
].join("\n");
|
|
43463
|
+
}
|
|
43464
|
+
function synthesizeScanConfig(eco) {
|
|
43465
|
+
return parseConfig(buildScanConfigYaml(eco), "<border scan>");
|
|
43466
|
+
}
|
|
43467
|
+
|
|
43468
|
+
// src/scan/fetch.ts
|
|
43469
|
+
import { createHash as createHash15 } from "node:crypto";
|
|
43470
|
+
import { basename as basename4 } from "node:path";
|
|
43471
|
+
var SCAN_MAX_ARTIFACT_BYTES = 200 * 1024 * 1024;
|
|
43472
|
+
var SCAN_REQUEST_TIMEOUT_MS = 6e4;
|
|
43473
|
+
var SCAN_USER_AGENT = "border-customs/0.3.2 (+https://github.com/sumteclab/border; border scan)";
|
|
43474
|
+
var CRATES_DOWNLOAD_HOST = "https://static.crates.io";
|
|
43475
|
+
var defaultScanFetcher = async (url2, init) => {
|
|
43476
|
+
if (!/^https?:\/\//.test(url2)) throw new EngineRunError(`border scan refuses non-http(s) URL: ${url2}`, null);
|
|
43477
|
+
const res = await fetch(url2, {
|
|
43478
|
+
redirect: "follow",
|
|
43479
|
+
signal: init.signal,
|
|
43480
|
+
headers: { "user-agent": SCAN_USER_AGENT, ...init.headers ?? {} }
|
|
43481
|
+
});
|
|
43482
|
+
const raw = res.headers.get("content-length");
|
|
43483
|
+
const parsed = raw === null ? Number.NaN : Number(raw);
|
|
43484
|
+
return {
|
|
43485
|
+
status: res.status,
|
|
43486
|
+
contentLength: Number.isFinite(parsed) && parsed >= 0 ? parsed : null,
|
|
43487
|
+
chunks: () => {
|
|
43488
|
+
if (res.body === null) {
|
|
43489
|
+
return {
|
|
43490
|
+
async *[Symbol.asyncIterator]() {
|
|
43491
|
+
}
|
|
43492
|
+
};
|
|
43493
|
+
}
|
|
43494
|
+
return res.body;
|
|
43495
|
+
}
|
|
43496
|
+
};
|
|
43497
|
+
};
|
|
43498
|
+
var fail = (why) => {
|
|
43499
|
+
throw new EngineRunError(`border scan could not fetch the artifact (${why}) \u2014 refusing to scan a partial or absent download (fail-closed)`, null);
|
|
43500
|
+
};
|
|
43501
|
+
async function readCapped(res, url2, cap = SCAN_MAX_ARTIFACT_BYTES) {
|
|
43502
|
+
if (res.status !== 200) fail(`HTTP ${String(res.status)} for ${url2}`);
|
|
43503
|
+
if (res.contentLength !== null && res.contentLength > cap) {
|
|
43504
|
+
fail(`${url2} announces ${String(res.contentLength)} bytes, over the 200 MiB scan size cap`);
|
|
43505
|
+
}
|
|
43506
|
+
const parts = [];
|
|
43507
|
+
let total = 0;
|
|
43508
|
+
for await (const chunk of res.chunks()) {
|
|
43509
|
+
total += chunk.byteLength;
|
|
43510
|
+
if (total > cap) fail(`${url2} streams past the 200 MiB scan size cap after ${String(total)} bytes`);
|
|
43511
|
+
parts.push(Buffer.from(chunk));
|
|
43512
|
+
}
|
|
43513
|
+
return Buffer.concat(parts);
|
|
43514
|
+
}
|
|
43515
|
+
async function fetchBytes(fetcher, url2, timeoutMs, headers) {
|
|
43516
|
+
const res = await fetcher(url2, { signal: AbortSignal.timeout(timeoutMs), ...headers !== void 0 ? { headers } : {} });
|
|
43517
|
+
return readCapped(res, url2);
|
|
43518
|
+
}
|
|
43519
|
+
async function fetchJson(fetcher, url2, timeoutMs) {
|
|
43520
|
+
const body = await fetchBytes(fetcher, url2, timeoutMs);
|
|
43521
|
+
try {
|
|
43522
|
+
return JSON.parse(body.toString("utf8"));
|
|
43523
|
+
} catch {
|
|
43524
|
+
fail(`metadata response from ${url2} is not valid JSON`);
|
|
43525
|
+
}
|
|
43526
|
+
}
|
|
43527
|
+
var isRecord2 = (v) => typeof v === "object" && v !== null;
|
|
43528
|
+
function channelBaseUrl(eco) {
|
|
43529
|
+
const ch = publishChannels().find((c) => c.id === eco);
|
|
43530
|
+
if (ch === void 0 || ch.defaultUrl.length === 0) {
|
|
43531
|
+
throw new EngineRunError(`border scan: channel '${eco}' exposes no defaultUrl`, null);
|
|
43532
|
+
}
|
|
43533
|
+
return ch.defaultUrl;
|
|
43534
|
+
}
|
|
43535
|
+
function npmMetadataUrl(name, version2, base = channelBaseUrl("npm")) {
|
|
43536
|
+
const enc = name.startsWith("@") ? name.replace("/", "%2F") : name;
|
|
43537
|
+
return `${base}/${enc}/${version2}`;
|
|
43538
|
+
}
|
|
43539
|
+
function pypiMetadataUrl(name, version2, base = channelBaseUrl("pypi")) {
|
|
43540
|
+
return `${base}/pypi/${encodeURIComponent(name)}/${version2}/json`;
|
|
43541
|
+
}
|
|
43542
|
+
function cratesDownloadUrl(name, version2) {
|
|
43543
|
+
return `${CRATES_DOWNLOAD_HOST}/crates/${name}/${name}-${version2}.crate`;
|
|
43544
|
+
}
|
|
43545
|
+
function rubygemsDownloadUrl(name, version2, base = channelBaseUrl("rubygems")) {
|
|
43546
|
+
return `${base}/downloads/${name}-${version2}.gem`;
|
|
43547
|
+
}
|
|
43548
|
+
function safeArtifactName(raw, fallback) {
|
|
43549
|
+
const base = basename4(raw.split("?")[0] ?? "").replace(/[^A-Za-z0-9._+-]/g, "_");
|
|
43550
|
+
return base.length > 0 && base !== "." && base !== ".." ? base : fallback;
|
|
43551
|
+
}
|
|
43552
|
+
async function fetchArtifact(spec, o) {
|
|
43553
|
+
const timeoutMs = o.timeoutMs ?? SCAN_REQUEST_TIMEOUT_MS;
|
|
43554
|
+
const label = `${spec.ecosystem}:${spec.name}@${spec.version}`;
|
|
43555
|
+
switch (spec.ecosystem) {
|
|
43556
|
+
case "npm": {
|
|
43557
|
+
const meta3 = await fetchJson(o.fetcher, npmMetadataUrl(spec.name, spec.version), timeoutMs);
|
|
43558
|
+
const dist = isRecord2(meta3) && isRecord2(meta3["dist"]) ? meta3["dist"] : {};
|
|
43559
|
+
const tarball = dist["tarball"];
|
|
43560
|
+
if (typeof tarball !== "string" || tarball.length === 0) fail(`npm metadata for ${label} has no dist.tarball`);
|
|
43561
|
+
if (!/^https?:\/\//.test(tarball)) fail(`npm dist.tarball for ${label} is not an http(s) URL`);
|
|
43562
|
+
const bytes = await fetchBytes(o.fetcher, tarball, timeoutMs);
|
|
43563
|
+
const filename = safeArtifactName(tarball, `${spec.name.replace("@", "").replace("/", "-")}-${spec.version}.tgz`);
|
|
43564
|
+
if (!filename.endsWith(".tgz") && !filename.endsWith(".tar.gz")) fail(`npm tarball ${filename} is not a .tgz/.tar.gz`);
|
|
43565
|
+
return { bytes, filename };
|
|
43566
|
+
}
|
|
43567
|
+
case "pypi": {
|
|
43568
|
+
const meta3 = await fetchJson(o.fetcher, pypiMetadataUrl(spec.name, spec.version), timeoutMs);
|
|
43569
|
+
const urls = isRecord2(meta3) && Array.isArray(meta3["urls"]) ? meta3["urls"] : [];
|
|
43570
|
+
const files = urls.filter(isRecord2);
|
|
43571
|
+
const sdists = files.filter((f) => f["packagetype"] === "sdist");
|
|
43572
|
+
const sdist = sdists.find((f) => typeof f["filename"] === "string" && f["filename"].endsWith(".tar.gz"));
|
|
43573
|
+
if (sdist === void 0) {
|
|
43574
|
+
fail(`no .tar.gz sdist published for ${label} \u2014 wheels are pre-built artifacts and out of scan scope (setup code lives in the sdist)`);
|
|
43575
|
+
}
|
|
43576
|
+
const url2 = sdist["url"];
|
|
43577
|
+
const name = sdist["filename"];
|
|
43578
|
+
if (typeof url2 !== "string" || typeof name !== "string") fail(`PyPI sdist entry for ${label} is malformed`);
|
|
43579
|
+
const bytes = await fetchBytes(o.fetcher, url2, timeoutMs);
|
|
43580
|
+
return { bytes, filename: safeArtifactName(url2, name) };
|
|
43581
|
+
}
|
|
43582
|
+
case "crates": {
|
|
43583
|
+
const url2 = cratesDownloadUrl(spec.name, spec.version);
|
|
43584
|
+
const bytes = await fetchBytes(o.fetcher, url2, timeoutMs, { "user-agent": SCAN_USER_AGENT });
|
|
43585
|
+
return { bytes, filename: `${spec.name}-${spec.version}.crate` };
|
|
43586
|
+
}
|
|
43587
|
+
case "rubygems": {
|
|
43588
|
+
const url2 = rubygemsDownloadUrl(spec.name, spec.version);
|
|
43589
|
+
const bytes = await fetchBytes(o.fetcher, url2, timeoutMs, { "user-agent": SCAN_USER_AGENT });
|
|
43590
|
+
return { bytes, filename: `${spec.name}-${spec.version}.gem` };
|
|
43591
|
+
}
|
|
43592
|
+
default: {
|
|
43593
|
+
const eco = spec.ecosystem;
|
|
43594
|
+
throw new UnknownArgError(`unknown ecosystem '${String(eco)}' \u2014 border scan cannot fetch it`);
|
|
43595
|
+
}
|
|
43596
|
+
}
|
|
43597
|
+
}
|
|
43598
|
+
function sha256Hex(data) {
|
|
43599
|
+
return createHash15("sha256").update(data).digest("hex");
|
|
43600
|
+
}
|
|
43601
|
+
|
|
43602
|
+
// src/scan/materialize.ts
|
|
43603
|
+
import { existsSync as existsSync17, mkdirSync as mkdirSync11, readdirSync as readdirSync10, readFileSync as readFileSync16, rmSync as rmSync12, writeFileSync as writeFileSync7 } from "node:fs";
|
|
43604
|
+
import { join as join25 } from "node:path";
|
|
43605
|
+
var CRATES_ENVELOPE_FILES = ["Cargo.toml.orig", ".cargo-ok", ".cargo_vcs_info.json"];
|
|
43606
|
+
var fail2 = (why) => {
|
|
43607
|
+
throw new Error(`border scan cannot materialize the artifact (${why}) \u2014 refusing to stage an unusable tree (fail-closed)`);
|
|
43608
|
+
};
|
|
43609
|
+
var oneLine = (err) => (err instanceof Error ? err.message : String(err)).replace(/\s+/g, " ").trim();
|
|
43610
|
+
function extractFlat(archive, destDir) {
|
|
43611
|
+
try {
|
|
43612
|
+
extractArchive(archive, destDir);
|
|
43613
|
+
} catch (err) {
|
|
43614
|
+
fail2(`extraction of ${archive} failed: ${oneLine(err)}`);
|
|
43615
|
+
}
|
|
43616
|
+
}
|
|
43617
|
+
function unwrapSingleDir(dir) {
|
|
43618
|
+
const entries = readdirSync10(dir, { withFileTypes: true });
|
|
43619
|
+
const dirs = entries.filter((e) => e.isDirectory());
|
|
43620
|
+
if (entries.length === 1 && dirs.length === 1 && dirs[0] !== void 0) {
|
|
43621
|
+
return join25(dir, dirs[0].name);
|
|
43622
|
+
}
|
|
43623
|
+
return dir;
|
|
43624
|
+
}
|
|
43625
|
+
function requireManifest(repoDir, ecosystem, candidates) {
|
|
43626
|
+
if (!candidates.some((rel) => existsSync17(join25(repoDir, rel)))) {
|
|
43627
|
+
fail2(`${ecosystem} package tree has none of [${candidates.join(", ")}] at its root`);
|
|
43628
|
+
}
|
|
43629
|
+
}
|
|
43630
|
+
function materializePackage(o) {
|
|
43631
|
+
const note = o.note ?? ((line) => {
|
|
43632
|
+
process.stderr.write(`${line}
|
|
43633
|
+
`);
|
|
43634
|
+
});
|
|
43635
|
+
const safe = safeArtifactName(o.filename, `${o.ecosystem}-artifact`);
|
|
43636
|
+
const archive = join25(o.baseDir, safe);
|
|
43637
|
+
writeFileSync7(archive, o.bytes);
|
|
43638
|
+
const extractDir = join25(o.baseDir, "extract");
|
|
43639
|
+
mkdirSync11(extractDir, { recursive: true });
|
|
43640
|
+
extractFlat(archive, extractDir);
|
|
43641
|
+
let repoDir = unwrapSingleDir(extractDir);
|
|
43642
|
+
if (o.ecosystem === "rubygems") {
|
|
43643
|
+
const outer = repoDir;
|
|
43644
|
+
const dataCandidates = findNativeMissArchives(outer).filter((f) => f.endsWith("data.tar.gz"));
|
|
43645
|
+
const inner = dataCandidates[0] ?? join25(outer, "data.tar.gz");
|
|
43646
|
+
if (!existsSync17(inner)) fail2(".gem archive contains no data.tar.gz");
|
|
43647
|
+
const gemTree = join25(o.baseDir, "gem-tree");
|
|
43648
|
+
mkdirSync11(gemTree, { recursive: true });
|
|
43649
|
+
extractFlat(inner, gemTree);
|
|
43650
|
+
repoDir = unwrapSingleDir(gemTree);
|
|
43651
|
+
} else if (o.ecosystem === "npm") {
|
|
43652
|
+
requireManifest(repoDir, o.ecosystem, ["package.json"]);
|
|
43653
|
+
} else if (o.ecosystem === "pypi") {
|
|
43654
|
+
requireManifest(repoDir, o.ecosystem, ["pyproject.toml", "setup.py"]);
|
|
43655
|
+
} else {
|
|
43656
|
+
requireManifest(repoDir, o.ecosystem, ["Cargo.toml"]);
|
|
43657
|
+
for (const file2 of CRATES_ENVELOPE_FILES) {
|
|
43658
|
+
const path4 = join25(repoDir, file2);
|
|
43659
|
+
if (!existsSync17(path4)) continue;
|
|
43660
|
+
const original = readFileSync16(path4);
|
|
43661
|
+
rmSync12(path4, { force: true });
|
|
43662
|
+
note(`border scan: crates envelope normalized: ${file2} sha256=${sha256Hex(original)}`);
|
|
43663
|
+
}
|
|
43664
|
+
}
|
|
43665
|
+
const env2 = o.env;
|
|
43666
|
+
const git = (args) => runGitChecked(repoDir, args, ...env2 !== void 0 ? [{ env: env2 }] : [{}]);
|
|
43667
|
+
git(["init", "-q"]);
|
|
43668
|
+
git(["add", "-A"]);
|
|
43669
|
+
git(["-c", "user.email=border@local", "-c", "user.name=border", "commit", "-qm", "border scan"]);
|
|
43670
|
+
return repoDir;
|
|
43671
|
+
}
|
|
43672
|
+
|
|
43673
|
+
// src/scan/spec.ts
|
|
43674
|
+
var ECOSYSTEMS = ["npm", "pypi", "crates", "rubygems"];
|
|
43675
|
+
var SCAN_SPEC_USAGE = "usage: border scan <[ecosystem:]name@version> \u2014 e.g. border scan puppeteer@23.11.1 | pypi:requests@2.32.3 | crates:serde@1.0.219 | rubygems:rails@7.1.1";
|
|
43676
|
+
var PLAIN_NAME = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
|
|
43677
|
+
var SCOPED_NAME = /^@[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
|
|
43678
|
+
var VERSION = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
|
|
43679
|
+
var bad2 = (why) => {
|
|
43680
|
+
throw new UnknownArgError(`${why}; ${SCAN_SPEC_USAGE}`);
|
|
43681
|
+
};
|
|
43682
|
+
function parseScanSpec(raw) {
|
|
43683
|
+
const text = raw.trim();
|
|
43684
|
+
if (text.length === 0) return bad2("empty spec");
|
|
43685
|
+
let ecosystem = "npm";
|
|
43686
|
+
let rest = text;
|
|
43687
|
+
const colon = text.indexOf(":");
|
|
43688
|
+
if (colon >= 0) {
|
|
43689
|
+
const prefix = text.slice(0, colon);
|
|
43690
|
+
const found = ECOSYSTEMS.find((e) => e === prefix);
|
|
43691
|
+
if (found === void 0) {
|
|
43692
|
+
return bad2(`unknown ecosystem '${prefix}' \u2014 expected one of: ${ECOSYSTEMS.join(", ")}`);
|
|
43693
|
+
}
|
|
43694
|
+
ecosystem = found;
|
|
43695
|
+
rest = text.slice(colon + 1);
|
|
43696
|
+
if (rest.length === 0) return bad2("missing package name after the ecosystem prefix");
|
|
43697
|
+
}
|
|
43698
|
+
const scoped = rest.startsWith("@");
|
|
43699
|
+
const at = rest.lastIndexOf("@");
|
|
43700
|
+
if (at <= 0) return bad2(`spec '${raw}' is missing '@version'`);
|
|
43701
|
+
const name = rest.slice(0, at);
|
|
43702
|
+
const version2 = rest.slice(at + 1);
|
|
43703
|
+
if (!(scoped ? SCOPED_NAME : PLAIN_NAME).test(name)) {
|
|
43704
|
+
return bad2(`invalid package name '${name}'`);
|
|
43705
|
+
}
|
|
43706
|
+
if (!VERSION.test(version2)) {
|
|
43707
|
+
return bad2(`invalid version '${version2}' in spec '${raw}'`);
|
|
43708
|
+
}
|
|
43709
|
+
return { ecosystem, name, version: version2 };
|
|
43710
|
+
}
|
|
43711
|
+
|
|
43712
|
+
// src/scan/scan.ts
|
|
43713
|
+
async function runScanCore(ctx, deps = {}) {
|
|
43714
|
+
const raw = ctx.positionals[0];
|
|
43715
|
+
if (ctx.positionals.length !== 1 || raw === void 0) {
|
|
43716
|
+
throw new UnknownArgError(
|
|
43717
|
+
`border scan expects exactly one <spec> argument (got ${String(ctx.positionals.length)}); ${SCAN_SPEC_USAGE}`
|
|
43718
|
+
);
|
|
43719
|
+
}
|
|
43720
|
+
const spec = parseScanSpec(raw);
|
|
43721
|
+
const channel = publishChannels().find((c) => c.id === spec.ecosystem);
|
|
43722
|
+
if (channel === void 0) {
|
|
43723
|
+
throw new EngineRunError(`border scan: no channel registered for ecosystem '${spec.ecosystem}'`, null);
|
|
43724
|
+
}
|
|
43725
|
+
const cfg = synthesizeScanConfig(spec.ecosystem);
|
|
43726
|
+
const fetcher = deps.fetcher ?? defaultScanFetcher;
|
|
43727
|
+
const baseDir = mkdtempSync5(join26(tmpdir3(), "border-scan-"));
|
|
43728
|
+
try {
|
|
43729
|
+
const { bytes, filename } = await fetchArtifact(spec, { fetcher });
|
|
43730
|
+
const env2 = { ...ctx.env };
|
|
43731
|
+
const repoDir = materializePackage({ ecosystem: spec.ecosystem, bytes, filename, baseDir, env: env2 });
|
|
43732
|
+
const stage = await channel.stage({ repoDir, cfg, env: env2, sanitizer: new TextSanitizer() });
|
|
43733
|
+
const head = runGitChecked(repoDir, ["rev-parse", "HEAD"], { env: env2 }).trim();
|
|
43734
|
+
const findings = stage.findings;
|
|
43735
|
+
const verdict = computeVerdict(findings);
|
|
43736
|
+
const report = {
|
|
43737
|
+
schemaVersion: 1,
|
|
43738
|
+
key: sha256Hex(`scan:${spec.ecosystem}:${spec.name}@${spec.version}:${head}`),
|
|
43739
|
+
head,
|
|
43740
|
+
dirty: false,
|
|
43741
|
+
exposureSet: [`${spec.ecosystem}:${spec.name}@${spec.version}`],
|
|
43742
|
+
refSet: [],
|
|
43743
|
+
rulesHash: sha256Hex(buildScanConfigYaml(spec.ecosystem)),
|
|
43744
|
+
verdict,
|
|
43745
|
+
counts: countFindings(findings),
|
|
43746
|
+
findings,
|
|
43747
|
+
ts: (/* @__PURE__ */ new Date()).toISOString()
|
|
43748
|
+
};
|
|
43749
|
+
const label = `${spec.ecosystem}:${spec.name}@${spec.version}`;
|
|
43750
|
+
const sanitizer = new TextSanitizer();
|
|
43751
|
+
const line = (text) => sanitizer.sanitize(text).replace(/\s+/g, " ").trim();
|
|
43752
|
+
if (ctx.flags.json) {
|
|
43753
|
+
ctx.stdout(renderReportJson(report));
|
|
43754
|
+
} else {
|
|
43755
|
+
ctx.stdout(line(`border scan ${label} ${report.verdict}: ${String(report.counts.total)} finding(s), ${String(report.counts.blocking)} blocking`));
|
|
43756
|
+
for (const f of report.findings) {
|
|
43757
|
+
ctx.stdout(line(` ${f.severity} ${f.rule} [${f.engine}] ${f.path ?? f.commit ?? f.target} ${f.message}`));
|
|
43758
|
+
}
|
|
43759
|
+
}
|
|
43760
|
+
return exitCodeFromVerdict(verdict);
|
|
43761
|
+
} finally {
|
|
43762
|
+
removeSandbox(baseDir);
|
|
43763
|
+
}
|
|
43764
|
+
}
|
|
43765
|
+
|
|
43766
|
+
// src/commands/scan.ts
|
|
43767
|
+
var runScan2 = (ctx) => runScanCore(ctx);
|
|
43768
|
+
|
|
43430
43769
|
// src/commands/status.ts
|
|
43431
43770
|
function oneInertLine(value) {
|
|
43432
43771
|
return value.replace(/[\x00-\x1f\x7f]+/g, " ").trim();
|
|
@@ -43508,11 +43847,12 @@ var handlers = {
|
|
|
43508
43847
|
push: runPush,
|
|
43509
43848
|
status: runStatus,
|
|
43510
43849
|
"llm-request": runLlmRequest,
|
|
43511
|
-
"llm-ingest": runLlmIngest
|
|
43850
|
+
"llm-ingest": runLlmIngest,
|
|
43851
|
+
scan: runScan2
|
|
43512
43852
|
};
|
|
43513
43853
|
|
|
43514
43854
|
// src/cli/types.ts
|
|
43515
|
-
var SUBCOMMANDS = ["check", "push", "status", "llm-request", "llm-ingest"];
|
|
43855
|
+
var SUBCOMMANDS = ["check", "push", "status", "llm-request", "llm-ingest", "scan"];
|
|
43516
43856
|
|
|
43517
43857
|
// src/cli.ts
|
|
43518
43858
|
var VALID_TARGETS = targetIds();
|
|
@@ -43528,6 +43868,7 @@ function usage() {
|
|
|
43528
43868
|
" status show gate state, config, and engine versions",
|
|
43529
43869
|
" llm-request emit the provenance request for LLM-authored commits",
|
|
43530
43870
|
" llm-ingest record provenance captured by the harness",
|
|
43871
|
+
" scan inspect a third-party package ([ecosystem:]name@version) for residue",
|
|
43531
43872
|
"",
|
|
43532
43873
|
"flags:",
|
|
43533
43874
|
" --config <path> config file (default: ./border.yaml, then git-remote fallback)",
|
|
@@ -43541,7 +43882,7 @@ function usage() {
|
|
|
43541
43882
|
"",
|
|
43542
43883
|
"exit codes: 0 pass (MEDIUM/INFO/LOW allowed) | 1 gate-blocked (CRITICAL/HIGH) or partial push | 2 config/tool error",
|
|
43543
43884
|
"",
|
|
43544
|
-
"subcommands: check, push, status, llm-request, llm-ingest"
|
|
43885
|
+
"subcommands: check, push, status, llm-request, llm-ingest, scan"
|
|
43545
43886
|
].join("\n");
|
|
43546
43887
|
}
|
|
43547
43888
|
function isSubcommand(value) {
|
|
@@ -43650,11 +43991,18 @@ function run(argv, stdout = (line) => {
|
|
|
43650
43991
|
}
|
|
43651
43992
|
|
|
43652
43993
|
// src/index.ts
|
|
43653
|
-
function isMainModule() {
|
|
43654
|
-
|
|
43655
|
-
|
|
43994
|
+
function isMainModule({ argv1, moduleUrl, realpath }) {
|
|
43995
|
+
if (argv1 === void 0) return false;
|
|
43996
|
+
const canonical = (p) => {
|
|
43997
|
+
try {
|
|
43998
|
+
return realpath(p);
|
|
43999
|
+
} catch {
|
|
44000
|
+
return p;
|
|
44001
|
+
}
|
|
44002
|
+
};
|
|
44003
|
+
return pathToFileURL(canonical(argv1)).href === pathToFileURL(canonical(fileURLToPath5(moduleUrl))).href;
|
|
43656
44004
|
}
|
|
43657
|
-
if (isMainModule()) {
|
|
44005
|
+
if (isMainModule({ argv1: process.argv[1], moduleUrl: import.meta.url, realpath: realpathSync.native })) {
|
|
43658
44006
|
const outcome = run(process.argv.slice(2));
|
|
43659
44007
|
process.exitCode = outcome instanceof Promise ? await outcome : outcome;
|
|
43660
44008
|
}
|
|
@@ -43663,6 +44011,7 @@ export {
|
|
|
43663
44011
|
EXIT_ERROR,
|
|
43664
44012
|
EXIT_PASS,
|
|
43665
44013
|
SUBCOMMANDS,
|
|
44014
|
+
isMainModule,
|
|
43666
44015
|
run,
|
|
43667
44016
|
translateError,
|
|
43668
44017
|
usage
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "border-customs",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
|
+
"//": "publint is a runtime dep (exact pin): the npm-stage engine is invoked via bin resolution against the installed layout, so consumers get the publint-fail leg out of the box; the dev suite resolves the same node_modules/.bin/publint — no devDependencies entry is needed for it to stay green.",
|
|
4
5
|
"description": "Fail-closed git push gate: secret scanning + supply-chain checks before any remote leaves the machine",
|
|
5
6
|
"type": "module",
|
|
6
7
|
"bin": {
|
|
@@ -30,13 +31,13 @@
|
|
|
30
31
|
"@secretlint/secretlint-rule-no-homedir": "13.0.5",
|
|
31
32
|
"@secretlint/secretlint-rule-pattern": "13.0.5",
|
|
32
33
|
"@secretlint/secretlint-rule-preset-recommend": "13.0.5",
|
|
34
|
+
"publint": "0.3.24",
|
|
33
35
|
"yaml": "2.9.0",
|
|
34
36
|
"zod": "4.5.4"
|
|
35
37
|
},
|
|
36
38
|
"devDependencies": {
|
|
37
39
|
"@types/node": "22.19.0",
|
|
38
40
|
"esbuild": "0.25.12",
|
|
39
|
-
"publint": "0.3.24",
|
|
40
41
|
"secretlint": "13.0.5",
|
|
41
42
|
"typescript": "5.9.3",
|
|
42
43
|
"verdaccio": "6.10.2"
|