border-customs 0.3.0 → 0.3.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.
Files changed (3) hide show
  1. package/README.md +36 -1
  2. package/dist/index.js +350 -14
  3. package/package.json +1 -1
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,40 @@ 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
+
343
378
  ## Configuration
344
379
 
345
380
  Everything border can be told lives in `border.yaml` at the repo root. Unknown keys are
package/dist/index.js CHANGED
@@ -40394,8 +40394,8 @@ function exitCodeFromVerdict(verdict) {
40394
40394
  var MESSAGE_CAP = 512;
40395
40395
  var URL_IN_TEXT_RE = /(?:\w+:\/\/\S+|[A-Za-z0-9_.+-]+@[A-Za-z0-9_.+-]+:\S+)/g;
40396
40396
  function sanitizeMessage(message) {
40397
- const oneLine = message.replace(/\s+/g, " ").trim();
40398
- const scrubbed = oneLine.replace(URL_IN_TEXT_RE, (candidate) => sanitizeUrl(candidate));
40397
+ const oneLine2 = message.replace(/\s+/g, " ").trim();
40398
+ const scrubbed = oneLine2.replace(URL_IN_TEXT_RE, (candidate) => sanitizeUrl(candidate));
40399
40399
  return scrubbed.length > MESSAGE_CAP ? `${scrubbed.slice(0, MESSAGE_CAP - 3)}...` : scrubbed;
40400
40400
  }
40401
40401
  function labelFor(err) {
@@ -40442,17 +40442,17 @@ function requirePassedRecord(kind, i) {
40442
40442
  function rehashRecordedArtifacts(kind, extensions, record2, repoDir) {
40443
40443
  const conventions = extensions;
40444
40444
  const entries = (record2.artifacts ?? []).filter((a) => conventions.some((ext) => a.file.endsWith(ext)));
40445
- const fail = (detail) => ({ ok: false, message: `artifact changed since check \u2014 ${detail}` });
40445
+ const fail3 = (detail) => ({ ok: false, message: `artifact changed since check \u2014 ${detail}` });
40446
40446
  if (entries.length === 0) {
40447
- return fail(`the PASSED check recorded no ${kind} artifacts (${conventions.join("/")}) to re-verify`);
40447
+ return fail3(`the PASSED check recorded no ${kind} artifacts (${conventions.join("/")}) to re-verify`);
40448
40448
  }
40449
40449
  const files = [];
40450
40450
  for (const a of entries) {
40451
40451
  const rel = join16(DIST_DIR, basename3(a.file));
40452
40452
  const abs = join16(repoDir, rel);
40453
- if (!existsSync11(abs)) return fail(`${rel} is missing from the working tree`);
40453
+ if (!existsSync11(abs)) return fail3(`${rel} is missing from the working tree`);
40454
40454
  const digest2 = createHash10("sha256").update(readFileSync10(abs)).digest("hex");
40455
- if (digest2 !== a.sha256) return fail(`${rel} no longer hashes to the digest recorded by the check`);
40455
+ if (digest2 !== a.sha256) return fail3(`${rel} no longer hashes to the digest recorded by the check`);
40456
40456
  files.push(rel);
40457
40457
  }
40458
40458
  return { ok: true, files: files.sort() };
@@ -40911,7 +40911,7 @@ function npmExposureCoords(cfg, read) {
40911
40911
  return { name, version: version2 };
40912
40912
  }
40913
40913
  function classifyNpmVersionView(o) {
40914
- const fail = (why) => {
40914
+ const fail3 = (why) => {
40915
40915
  throw new EngineRunError(
40916
40916
  `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
40917
  o.status
@@ -40919,19 +40919,19 @@ function classifyNpmVersionView(o) {
40919
40919
  };
40920
40920
  if (o.status === 0) {
40921
40921
  const text = o.stdout.trim();
40922
- if (text.length === 0) return fail("exit 0 with empty stdout");
40922
+ if (text.length === 0) return fail3("exit 0 with empty stdout");
40923
40923
  let body;
40924
40924
  try {
40925
40925
  body = JSON.parse(text);
40926
40926
  } catch {
40927
- return fail("exit 0 with malformed JSON body");
40927
+ return fail3("exit 0 with malformed JSON body");
40928
40928
  }
40929
- if (typeof body !== "object" || body === null) return fail("exit 0 with non-object JSON body");
40929
+ if (typeof body !== "object" || body === null) return fail3("exit 0 with non-object JSON body");
40930
40930
  return "present";
40931
40931
  }
40932
40932
  if (/code E404\b/.test(o.stderr) || /\b404 Not Found\b/.test(o.stderr)) return "absent";
40933
40933
  const m = /code (E[A-Z0-9_]+|ERR_[A-Z0-9_]+)/.exec(o.stderr);
40934
- return fail(m === null ? `exit ${String(o.status)} without a 404 marker` : `npm error ${m[1]}`);
40934
+ return fail3(m === null ? `exit ${String(o.status)} without a 404 marker` : `npm error ${m[1]}`);
40935
40935
  }
40936
40936
  function npmNameClaimed(o) {
40937
40937
  if (o.status === 0) return true;
@@ -42024,6 +42024,10 @@ function expandInConfig(cfg, env2) {
42024
42024
  }
42025
42025
  return { ...cfg, targets };
42026
42026
  }
42027
+ function parseConfig(text, source = "<inline>", options = {}) {
42028
+ const doc = yamlToJson(text, source);
42029
+ return expandInConfig(validateDoc(doc, source), options.env ?? process.env);
42030
+ }
42027
42031
  function deepMergeRaw(base, overlay) {
42028
42032
  if (isRecord(base) && isRecord(overlay)) {
42029
42033
  const out = { ...base };
@@ -43427,6 +43431,336 @@ async function executeYesPush(ctx, loaded) {
43427
43431
  return EXIT_PASS;
43428
43432
  }
43429
43433
 
43434
+ // src/scan/scan.ts
43435
+ import { mkdtempSync as mkdtempSync5 } from "node:fs";
43436
+ import { tmpdir as tmpdir3 } from "node:os";
43437
+ import { join as join26 } from "node:path";
43438
+
43439
+ // src/scan/config.ts
43440
+ function buildScanConfigYaml(eco) {
43441
+ return [
43442
+ "version: 1",
43443
+ "targets:",
43444
+ " git:",
43445
+ " remotes: []",
43446
+ ` ${eco}: {}`,
43447
+ "rules:",
43448
+ " authors:",
43449
+ " emails: []",
43450
+ " names: []",
43451
+ " hosts: []",
43452
+ " ips: []",
43453
+ " pathPatterns: []",
43454
+ "residue:",
43455
+ " enabled: true",
43456
+ ""
43457
+ ].join("\n");
43458
+ }
43459
+ function synthesizeScanConfig(eco) {
43460
+ return parseConfig(buildScanConfigYaml(eco), "<border scan>");
43461
+ }
43462
+
43463
+ // src/scan/fetch.ts
43464
+ import { createHash as createHash15 } from "node:crypto";
43465
+ import { basename as basename4 } from "node:path";
43466
+ var SCAN_MAX_ARTIFACT_BYTES = 200 * 1024 * 1024;
43467
+ var SCAN_REQUEST_TIMEOUT_MS = 6e4;
43468
+ var SCAN_USER_AGENT = "border-customs/0.3.0 (+https://github.com/sumteclab/border; border scan)";
43469
+ var CRATES_DOWNLOAD_HOST = "https://static.crates.io";
43470
+ var defaultScanFetcher = async (url2, init) => {
43471
+ if (!/^https?:\/\//.test(url2)) throw new EngineRunError(`border scan refuses non-http(s) URL: ${url2}`, null);
43472
+ const res = await fetch(url2, {
43473
+ redirect: "follow",
43474
+ signal: init.signal,
43475
+ headers: { "user-agent": SCAN_USER_AGENT, ...init.headers ?? {} }
43476
+ });
43477
+ const raw = res.headers.get("content-length");
43478
+ const parsed = raw === null ? Number.NaN : Number(raw);
43479
+ return {
43480
+ status: res.status,
43481
+ contentLength: Number.isFinite(parsed) && parsed >= 0 ? parsed : null,
43482
+ chunks: () => {
43483
+ if (res.body === null) {
43484
+ return {
43485
+ async *[Symbol.asyncIterator]() {
43486
+ }
43487
+ };
43488
+ }
43489
+ return res.body;
43490
+ }
43491
+ };
43492
+ };
43493
+ var fail = (why) => {
43494
+ throw new EngineRunError(`border scan could not fetch the artifact (${why}) \u2014 refusing to scan a partial or absent download (fail-closed)`, null);
43495
+ };
43496
+ async function readCapped(res, url2, cap = SCAN_MAX_ARTIFACT_BYTES) {
43497
+ if (res.status !== 200) fail(`HTTP ${String(res.status)} for ${url2}`);
43498
+ if (res.contentLength !== null && res.contentLength > cap) {
43499
+ fail(`${url2} announces ${String(res.contentLength)} bytes, over the 200 MiB scan size cap`);
43500
+ }
43501
+ const parts = [];
43502
+ let total = 0;
43503
+ for await (const chunk of res.chunks()) {
43504
+ total += chunk.byteLength;
43505
+ if (total > cap) fail(`${url2} streams past the 200 MiB scan size cap after ${String(total)} bytes`);
43506
+ parts.push(Buffer.from(chunk));
43507
+ }
43508
+ return Buffer.concat(parts);
43509
+ }
43510
+ async function fetchBytes(fetcher, url2, timeoutMs, headers) {
43511
+ const res = await fetcher(url2, { signal: AbortSignal.timeout(timeoutMs), ...headers !== void 0 ? { headers } : {} });
43512
+ return readCapped(res, url2);
43513
+ }
43514
+ async function fetchJson(fetcher, url2, timeoutMs) {
43515
+ const body = await fetchBytes(fetcher, url2, timeoutMs);
43516
+ try {
43517
+ return JSON.parse(body.toString("utf8"));
43518
+ } catch {
43519
+ fail(`metadata response from ${url2} is not valid JSON`);
43520
+ }
43521
+ }
43522
+ var isRecord2 = (v) => typeof v === "object" && v !== null;
43523
+ function channelBaseUrl(eco) {
43524
+ const ch = publishChannels().find((c) => c.id === eco);
43525
+ if (ch === void 0 || ch.defaultUrl.length === 0) {
43526
+ throw new EngineRunError(`border scan: channel '${eco}' exposes no defaultUrl`, null);
43527
+ }
43528
+ return ch.defaultUrl;
43529
+ }
43530
+ function npmMetadataUrl(name, version2, base = channelBaseUrl("npm")) {
43531
+ const enc = name.startsWith("@") ? name.replace("/", "%2F") : name;
43532
+ return `${base}/${enc}/${version2}`;
43533
+ }
43534
+ function pypiMetadataUrl(name, version2, base = channelBaseUrl("pypi")) {
43535
+ return `${base}/pypi/${encodeURIComponent(name)}/${version2}/json`;
43536
+ }
43537
+ function cratesDownloadUrl(name, version2) {
43538
+ return `${CRATES_DOWNLOAD_HOST}/crates/${name}/${name}-${version2}.crate`;
43539
+ }
43540
+ function rubygemsDownloadUrl(name, version2, base = channelBaseUrl("rubygems")) {
43541
+ return `${base}/downloads/${name}-${version2}.gem`;
43542
+ }
43543
+ function safeArtifactName(raw, fallback) {
43544
+ const base = basename4(raw.split("?")[0] ?? "").replace(/[^A-Za-z0-9._+-]/g, "_");
43545
+ return base.length > 0 && base !== "." && base !== ".." ? base : fallback;
43546
+ }
43547
+ async function fetchArtifact(spec, o) {
43548
+ const timeoutMs = o.timeoutMs ?? SCAN_REQUEST_TIMEOUT_MS;
43549
+ const label = `${spec.ecosystem}:${spec.name}@${spec.version}`;
43550
+ switch (spec.ecosystem) {
43551
+ case "npm": {
43552
+ const meta3 = await fetchJson(o.fetcher, npmMetadataUrl(spec.name, spec.version), timeoutMs);
43553
+ const dist = isRecord2(meta3) && isRecord2(meta3["dist"]) ? meta3["dist"] : {};
43554
+ const tarball = dist["tarball"];
43555
+ if (typeof tarball !== "string" || tarball.length === 0) fail(`npm metadata for ${label} has no dist.tarball`);
43556
+ if (!/^https?:\/\//.test(tarball)) fail(`npm dist.tarball for ${label} is not an http(s) URL`);
43557
+ const bytes = await fetchBytes(o.fetcher, tarball, timeoutMs);
43558
+ const filename = safeArtifactName(tarball, `${spec.name.replace("@", "").replace("/", "-")}-${spec.version}.tgz`);
43559
+ if (!filename.endsWith(".tgz") && !filename.endsWith(".tar.gz")) fail(`npm tarball ${filename} is not a .tgz/.tar.gz`);
43560
+ return { bytes, filename };
43561
+ }
43562
+ case "pypi": {
43563
+ const meta3 = await fetchJson(o.fetcher, pypiMetadataUrl(spec.name, spec.version), timeoutMs);
43564
+ const urls = isRecord2(meta3) && Array.isArray(meta3["urls"]) ? meta3["urls"] : [];
43565
+ const files = urls.filter(isRecord2);
43566
+ const sdists = files.filter((f) => f["packagetype"] === "sdist");
43567
+ const sdist = sdists.find((f) => typeof f["filename"] === "string" && f["filename"].endsWith(".tar.gz"));
43568
+ if (sdist === void 0) {
43569
+ 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)`);
43570
+ }
43571
+ const url2 = sdist["url"];
43572
+ const name = sdist["filename"];
43573
+ if (typeof url2 !== "string" || typeof name !== "string") fail(`PyPI sdist entry for ${label} is malformed`);
43574
+ const bytes = await fetchBytes(o.fetcher, url2, timeoutMs);
43575
+ return { bytes, filename: safeArtifactName(url2, name) };
43576
+ }
43577
+ case "crates": {
43578
+ const url2 = cratesDownloadUrl(spec.name, spec.version);
43579
+ const bytes = await fetchBytes(o.fetcher, url2, timeoutMs, { "user-agent": SCAN_USER_AGENT });
43580
+ return { bytes, filename: `${spec.name}-${spec.version}.crate` };
43581
+ }
43582
+ case "rubygems": {
43583
+ const url2 = rubygemsDownloadUrl(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}.gem` };
43586
+ }
43587
+ default: {
43588
+ const eco = spec.ecosystem;
43589
+ throw new UnknownArgError(`unknown ecosystem '${String(eco)}' \u2014 border scan cannot fetch it`);
43590
+ }
43591
+ }
43592
+ }
43593
+ function sha256Hex(data) {
43594
+ return createHash15("sha256").update(data).digest("hex");
43595
+ }
43596
+
43597
+ // src/scan/materialize.ts
43598
+ import { existsSync as existsSync17, mkdirSync as mkdirSync11, readdirSync as readdirSync10, readFileSync as readFileSync16, rmSync as rmSync12, writeFileSync as writeFileSync7 } from "node:fs";
43599
+ import { join as join25 } from "node:path";
43600
+ var CRATES_ENVELOPE_FILES = ["Cargo.toml.orig", ".cargo-ok", ".cargo_vcs_info.json"];
43601
+ var fail2 = (why) => {
43602
+ throw new Error(`border scan cannot materialize the artifact (${why}) \u2014 refusing to stage an unusable tree (fail-closed)`);
43603
+ };
43604
+ var oneLine = (err) => (err instanceof Error ? err.message : String(err)).replace(/\s+/g, " ").trim();
43605
+ function extractFlat(archive, destDir) {
43606
+ try {
43607
+ extractArchive(archive, destDir);
43608
+ } catch (err) {
43609
+ fail2(`extraction of ${archive} failed: ${oneLine(err)}`);
43610
+ }
43611
+ }
43612
+ function unwrapSingleDir(dir) {
43613
+ const entries = readdirSync10(dir, { withFileTypes: true });
43614
+ const dirs = entries.filter((e) => e.isDirectory());
43615
+ if (entries.length === 1 && dirs.length === 1 && dirs[0] !== void 0) {
43616
+ return join25(dir, dirs[0].name);
43617
+ }
43618
+ return dir;
43619
+ }
43620
+ function requireManifest(repoDir, ecosystem, candidates) {
43621
+ if (!candidates.some((rel) => existsSync17(join25(repoDir, rel)))) {
43622
+ fail2(`${ecosystem} package tree has none of [${candidates.join(", ")}] at its root`);
43623
+ }
43624
+ }
43625
+ function materializePackage(o) {
43626
+ const note = o.note ?? ((line) => {
43627
+ process.stderr.write(`${line}
43628
+ `);
43629
+ });
43630
+ const safe = safeArtifactName(o.filename, `${o.ecosystem}-artifact`);
43631
+ const archive = join25(o.baseDir, safe);
43632
+ writeFileSync7(archive, o.bytes);
43633
+ const extractDir = join25(o.baseDir, "extract");
43634
+ mkdirSync11(extractDir, { recursive: true });
43635
+ extractFlat(archive, extractDir);
43636
+ let repoDir = unwrapSingleDir(extractDir);
43637
+ if (o.ecosystem === "rubygems") {
43638
+ const outer = repoDir;
43639
+ const dataCandidates = findNativeMissArchives(outer).filter((f) => f.endsWith("data.tar.gz"));
43640
+ const inner = dataCandidates[0] ?? join25(outer, "data.tar.gz");
43641
+ if (!existsSync17(inner)) fail2(".gem archive contains no data.tar.gz");
43642
+ const gemTree = join25(o.baseDir, "gem-tree");
43643
+ mkdirSync11(gemTree, { recursive: true });
43644
+ extractFlat(inner, gemTree);
43645
+ repoDir = unwrapSingleDir(gemTree);
43646
+ } else if (o.ecosystem === "npm") {
43647
+ requireManifest(repoDir, o.ecosystem, ["package.json"]);
43648
+ } else if (o.ecosystem === "pypi") {
43649
+ requireManifest(repoDir, o.ecosystem, ["pyproject.toml", "setup.py"]);
43650
+ } else {
43651
+ requireManifest(repoDir, o.ecosystem, ["Cargo.toml"]);
43652
+ for (const file2 of CRATES_ENVELOPE_FILES) {
43653
+ const path4 = join25(repoDir, file2);
43654
+ if (!existsSync17(path4)) continue;
43655
+ const original = readFileSync16(path4);
43656
+ rmSync12(path4, { force: true });
43657
+ note(`border scan: crates envelope normalized: ${file2} sha256=${sha256Hex(original)}`);
43658
+ }
43659
+ }
43660
+ const env2 = o.env;
43661
+ const git = (args) => runGitChecked(repoDir, args, ...env2 !== void 0 ? [{ env: env2 }] : [{}]);
43662
+ git(["init", "-q"]);
43663
+ git(["add", "-A"]);
43664
+ git(["-c", "user.email=border@local", "-c", "user.name=border", "commit", "-qm", "border scan"]);
43665
+ return repoDir;
43666
+ }
43667
+
43668
+ // src/scan/spec.ts
43669
+ var ECOSYSTEMS = ["npm", "pypi", "crates", "rubygems"];
43670
+ 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";
43671
+ var PLAIN_NAME = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
43672
+ var SCOPED_NAME = /^@[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
43673
+ var VERSION = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
43674
+ var bad2 = (why) => {
43675
+ throw new UnknownArgError(`${why}; ${SCAN_SPEC_USAGE}`);
43676
+ };
43677
+ function parseScanSpec(raw) {
43678
+ const text = raw.trim();
43679
+ if (text.length === 0) return bad2("empty spec");
43680
+ let ecosystem = "npm";
43681
+ let rest = text;
43682
+ const colon = text.indexOf(":");
43683
+ if (colon >= 0) {
43684
+ const prefix = text.slice(0, colon);
43685
+ const found = ECOSYSTEMS.find((e) => e === prefix);
43686
+ if (found === void 0) {
43687
+ return bad2(`unknown ecosystem '${prefix}' \u2014 expected one of: ${ECOSYSTEMS.join(", ")}`);
43688
+ }
43689
+ ecosystem = found;
43690
+ rest = text.slice(colon + 1);
43691
+ if (rest.length === 0) return bad2("missing package name after the ecosystem prefix");
43692
+ }
43693
+ const scoped = rest.startsWith("@");
43694
+ const at = rest.lastIndexOf("@");
43695
+ if (at <= 0) return bad2(`spec '${raw}' is missing '@version'`);
43696
+ const name = rest.slice(0, at);
43697
+ const version2 = rest.slice(at + 1);
43698
+ if (!(scoped ? SCOPED_NAME : PLAIN_NAME).test(name)) {
43699
+ return bad2(`invalid package name '${name}'`);
43700
+ }
43701
+ if (!VERSION.test(version2)) {
43702
+ return bad2(`invalid version '${version2}' in spec '${raw}'`);
43703
+ }
43704
+ return { ecosystem, name, version: version2 };
43705
+ }
43706
+
43707
+ // src/scan/scan.ts
43708
+ async function runScanCore(ctx, deps = {}) {
43709
+ const raw = ctx.positionals[0];
43710
+ if (ctx.positionals.length !== 1 || raw === void 0) {
43711
+ throw new UnknownArgError(
43712
+ `border scan expects exactly one <spec> argument (got ${String(ctx.positionals.length)}); ${SCAN_SPEC_USAGE}`
43713
+ );
43714
+ }
43715
+ const spec = parseScanSpec(raw);
43716
+ const channel = publishChannels().find((c) => c.id === spec.ecosystem);
43717
+ if (channel === void 0) {
43718
+ throw new EngineRunError(`border scan: no channel registered for ecosystem '${spec.ecosystem}'`, null);
43719
+ }
43720
+ const cfg = synthesizeScanConfig(spec.ecosystem);
43721
+ const fetcher = deps.fetcher ?? defaultScanFetcher;
43722
+ const baseDir = mkdtempSync5(join26(tmpdir3(), "border-scan-"));
43723
+ try {
43724
+ const { bytes, filename } = await fetchArtifact(spec, { fetcher });
43725
+ const env2 = { ...ctx.env };
43726
+ const repoDir = materializePackage({ ecosystem: spec.ecosystem, bytes, filename, baseDir, env: env2 });
43727
+ const stage = await channel.stage({ repoDir, cfg, env: env2, sanitizer: new TextSanitizer() });
43728
+ const head = runGitChecked(repoDir, ["rev-parse", "HEAD"], { env: env2 }).trim();
43729
+ const findings = stage.findings;
43730
+ const verdict = computeVerdict(findings);
43731
+ const report = {
43732
+ schemaVersion: 1,
43733
+ key: sha256Hex(`scan:${spec.ecosystem}:${spec.name}@${spec.version}:${head}`),
43734
+ head,
43735
+ dirty: false,
43736
+ exposureSet: [`${spec.ecosystem}:${spec.name}@${spec.version}`],
43737
+ refSet: [],
43738
+ rulesHash: sha256Hex(buildScanConfigYaml(spec.ecosystem)),
43739
+ verdict,
43740
+ counts: countFindings(findings),
43741
+ findings,
43742
+ ts: (/* @__PURE__ */ new Date()).toISOString()
43743
+ };
43744
+ const label = `${spec.ecosystem}:${spec.name}@${spec.version}`;
43745
+ const sanitizer = new TextSanitizer();
43746
+ const line = (text) => sanitizer.sanitize(text).replace(/\s+/g, " ").trim();
43747
+ if (ctx.flags.json) {
43748
+ ctx.stdout(renderReportJson(report));
43749
+ } else {
43750
+ ctx.stdout(line(`border scan ${label} ${report.verdict}: ${String(report.counts.total)} finding(s), ${String(report.counts.blocking)} blocking`));
43751
+ for (const f of report.findings) {
43752
+ ctx.stdout(line(` ${f.severity} ${f.rule} [${f.engine}] ${f.path ?? f.commit ?? f.target} ${f.message}`));
43753
+ }
43754
+ }
43755
+ return exitCodeFromVerdict(verdict);
43756
+ } finally {
43757
+ removeSandbox(baseDir);
43758
+ }
43759
+ }
43760
+
43761
+ // src/commands/scan.ts
43762
+ var runScan2 = (ctx) => runScanCore(ctx);
43763
+
43430
43764
  // src/commands/status.ts
43431
43765
  function oneInertLine(value) {
43432
43766
  return value.replace(/[\x00-\x1f\x7f]+/g, " ").trim();
@@ -43508,11 +43842,12 @@ var handlers = {
43508
43842
  push: runPush,
43509
43843
  status: runStatus,
43510
43844
  "llm-request": runLlmRequest,
43511
- "llm-ingest": runLlmIngest
43845
+ "llm-ingest": runLlmIngest,
43846
+ scan: runScan2
43512
43847
  };
43513
43848
 
43514
43849
  // src/cli/types.ts
43515
- var SUBCOMMANDS = ["check", "push", "status", "llm-request", "llm-ingest"];
43850
+ var SUBCOMMANDS = ["check", "push", "status", "llm-request", "llm-ingest", "scan"];
43516
43851
 
43517
43852
  // src/cli.ts
43518
43853
  var VALID_TARGETS = targetIds();
@@ -43528,6 +43863,7 @@ function usage() {
43528
43863
  " status show gate state, config, and engine versions",
43529
43864
  " llm-request emit the provenance request for LLM-authored commits",
43530
43865
  " llm-ingest record provenance captured by the harness",
43866
+ " scan inspect a third-party package ([ecosystem:]name@version) for residue",
43531
43867
  "",
43532
43868
  "flags:",
43533
43869
  " --config <path> config file (default: ./border.yaml, then git-remote fallback)",
@@ -43541,7 +43877,7 @@ function usage() {
43541
43877
  "",
43542
43878
  "exit codes: 0 pass (MEDIUM/INFO/LOW allowed) | 1 gate-blocked (CRITICAL/HIGH) or partial push | 2 config/tool error",
43543
43879
  "",
43544
- "subcommands: check, push, status, llm-request, llm-ingest"
43880
+ "subcommands: check, push, status, llm-request, llm-ingest, scan"
43545
43881
  ].join("\n");
43546
43882
  }
43547
43883
  function isSubcommand(value) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "border-customs",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Fail-closed git push gate: secret scanning + supply-chain checks before any remote leaves the machine",
5
5
  "type": "module",
6
6
  "bin": {