skill-family-harness-node 0.4.0 → 0.5.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.
- package/CHANGELOG.md +24 -0
- package/CHANGELOG.zh-CN.md +24 -0
- package/README.md +13 -12
- package/README.zh-CN.md +13 -12
- package/package.json +5 -2
- package/release-notes/0.5.0.yaml +29 -0
- package/src/declared-read-surface.mjs +198 -0
- package/src/errors.mjs +4 -0
- package/src/index.mjs +6 -0
- package/src/structured-scan.mjs +652 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
<!-- release-skill:changelog:start version=0.5.0 locale=en baseline=sha256:03eca87a8c9bea814e741518168696f17c869bdb71baf2046cef4c3596d9ebf3 -->
|
|
4
|
+
## [0.5.0] - 2026-08-16
|
|
5
|
+
|
|
6
|
+
This release adds the declared read surface assertion (FND-ADR-010) and the structured surface scanner (FND-ADR-011) to the stable Harness surface, growing HARNESS_CAPABILITIES from 16 to 18 entries, and adds three dependency-reviewed runtime libraries.
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- Adds assertDeclaredReadSurface (FND-ADR-010): a no-execution, syntax-surface-only assertion that every node:fs named import inside a declared module set stays inside the consumer-declared read surface, with a closed violation vocabulary and a frozen declared-read-surface-result envelope.
|
|
11
|
+
- Adds scanSurfaceStructured (FND-ADR-011): the structured sibling of scanSurface. IP-shaped candidates enter a single standard parse entry (ipaddr.js) with consumer-declared CIDR approval and fail closed when unparseable; scoped and unscoped coordinates, registries and hosts need consumer-declared approval; format adapters (pnpm-lockfile via @pnpm/lockfile.fs with yaml AST comment regions, and tree-json) parse structurally with no position-level exemptions; binary and symlink policies fail closed. The closed nine-rule vocabulary travels in details.rule of the structured-scan-violation mechanism error.
|
|
12
|
+
- Adds three dependency-reviewed runtime libraries for the structured scanner, reviewed per FND-ADR-006 section-4 analogy (dependency-closure pre-review, executor self-review pending independent review): ipaddr.js 2.5.0 (MIT, zero dependencies), yaml 2.9.0 (ISC), and @pnpm/lockfile.fs 1001.1.35 (MIT, 18 transitive dependencies all inside the approved public coordinates).
|
|
13
|
+
- Grows HARNESS_CAPABILITIES from 16 to 18 entries for the two new mechanisms.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Documents the relationship of the two policy documents to workspace-private leak policies: a workspace-private leak-policy.json instance document is not a subset, not isomorphic and not a migration target of the surface-scan-policy or structured-scan-policy schemas — the documents share rule vocabulary and fail-closed semantics by design, but their byte-level shapes are independent and must not be compared for compatibility. scanSurface is the execution-core generalization projection: the public, consumer-parameterized form of the same mechanism family, without any private identity, path, or approval-list interpretation of its own.
|
|
18
|
+
- Records the cost of the dependency-review decision honestly: the harness tarball grows with the @pnpm/lockfile.fs closure, the thin-runtime property changes from zero third-party runtime dependencies to three reviewed ones, and the pnpm-lockfile adapter writes one temporary lockfile copy under the OS temp directory (removed afterwards). The engineering-kit offline-consumer verification gates now derive the complete third-party production closure of the three Foundation packages mechanically (identity-deduplicated, npm: alias-aware, range-scoped override selectors) instead of a single-package closure, so the review decision is continuously verified against the real installed bytes.
|
|
19
|
+
- Keeps mechanism purity: no execution of scanned files, no model calls, no network; symlinked entries are never followed.
|
|
20
|
+
|
|
21
|
+
### Upgrade Notes
|
|
22
|
+
|
|
23
|
+
Version 0.5.0 is the FND-ADR-010/011 harness line. Mechanism imports use the stable capability names published in HARNESS_CAPABILITIES; structured-scan policies must pass the structured-scan-policy contract validation of contract-spec 1.5.0.
|
|
24
|
+
<!-- release-skill:changelog:end version=0.5.0 locale=en -->
|
|
25
|
+
|
|
26
|
+
|
|
3
27
|
<!-- release-skill:changelog:start version=0.4.0 locale=en baseline=sha256:72d3fe048ced8eb44a84fabe6f1fdd2eb0c938b4f6bb98740474f72aec04d873 -->
|
|
4
28
|
## [0.4.0] - 2026-08-16
|
|
5
29
|
|
package/CHANGELOG.zh-CN.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# 变更日志
|
|
2
2
|
|
|
3
|
+
<!-- release-skill:changelog:start version=0.5.0 locale=zh-CN baseline=sha256:003472359596147fe26ad2ba53a82303a217c28cb89a6afacdaa955793001f71 -->
|
|
4
|
+
## [0.5.0] - 2026-08-16
|
|
5
|
+
|
|
6
|
+
本版在稳定 Harness 面上新增声明读取表面断言(FND-ADR-010)与结构化表面扫描器(FND-ADR-011),HARNESS_CAPABILITIES 从 16 项增至 18 项,并引入三个经依赖评审的运行时库。
|
|
7
|
+
|
|
8
|
+
### 新增
|
|
9
|
+
|
|
10
|
+
- 新增 assertDeclaredReadSurface(FND-ADR-010):不执行、仅语法面的断言,声明模块集内的每个 node:fs 具名导入必须落在消费者声明的读取表面内,违规词汇表为闭集,返回冻结的 declared-read-surface-result 信封。
|
|
11
|
+
- 新增 scanSurfaceStructured(FND-ADR-011):scanSurface 的结构化兄弟。IP 形候选统一进入单一标准解析入口(ipaddr.js),按消费者声明的 CIDR 批准,不可解析即失败关闭;坐标(scoped 与非 scoped)、注册表与主机均需消费者声明批准;格式适配器(pnpm-lockfile 经 @pnpm/lockfile.fs + yaml AST 注释区域,tree-json 经 JSON.parse)结构化解析且无位置级豁免;二进制与符号链接策略失败关闭。闭集九规则词汇表经 structured-scan-violation 机制错误的 details.rule 承载。
|
|
12
|
+
- 为结构化扫描器引入三个经依赖评审的运行时库,评审按 FND-ADR-006 第 4 节类比执行(依赖闭包预审,执行者自审,独立复核另行安排):ipaddr.js 2.5.0(MIT,零依赖)、yaml 2.9.0(ISC)、@pnpm/lockfile.fs 1001.1.35(MIT,18 个传递依赖全部在批准公共坐标内)。
|
|
13
|
+
- HARNESS_CAPABILITIES 从 16 项增至 18 项,覆盖两个新机制。
|
|
14
|
+
|
|
15
|
+
### 变更
|
|
16
|
+
|
|
17
|
+
- 显式说明两个策略文档与工作区私有 leak 策略的关系:工作区私有的 leak-policy.json 实例文档既不是 surface-scan-policy 或 structured-scan-policy schema 的子集、也不同构、更不是迁移目标——两类文档按设计共享规则词汇与失败关闭语义,但字节级形状相互独立,不得比较兼容性。scanSurface 是执行内核通用化投影:同一机制族的公开、消费者参数化形态,自身不解释任何私有身份、路径或批准清单。
|
|
18
|
+
- 如实记录依赖评审决策的成本:harness tarball 随 @pnpm/lockfile.fs 闭包扩大;薄运行时属性从零第三方运行时依赖变为三个经评审依赖;pnpm-lockfile 适配器会在 OS 临时目录写一份临时锁文件副本(用后即删)。engineering-kit 的离线消费者验证门随之把第三方闭包推导从单包闭包机械扩展为三个 Foundation 包的完整生产闭包(真实身份去重、npm: 别名感知、range-scoped override selector),使评审决策持续对着真实安装字节被验证。
|
|
19
|
+
- 保持机制纯度:不执行被扫描文件、无模型调用、无网络;永不跟随符号链接。
|
|
20
|
+
|
|
21
|
+
### 升级说明
|
|
22
|
+
|
|
23
|
+
0.5.0 是 FND-ADR-010/011 harness 线。机制导入使用 HARNESS_CAPABILITIES 公布的稳定能力名;structured-scan 策略必须通过契约规格 1.5.0 的 structured-scan-policy 契约校验。
|
|
24
|
+
<!-- release-skill:changelog:end version=0.5.0 locale=zh-CN -->
|
|
25
|
+
|
|
26
|
+
|
|
3
27
|
<!-- release-skill:changelog:start version=0.4.0 locale=zh-CN baseline=sha256:dafaa1100bf6163a28d53fc982a30e8325d8bd3cab43bbb6daf2e6002befa2c1 -->
|
|
4
28
|
## [0.4.0] - 2026-08-16
|
|
5
29
|
|
package/README.md
CHANGED
|
@@ -4,30 +4,31 @@
|
|
|
4
4
|
|
|
5
5
|
# skill-family-harness-node
|
|
6
6
|
|
|
7
|
-
<!-- release-skill:release-version: 0.
|
|
7
|
+
<!-- release-skill:release-version: 0.5.0 -->
|
|
8
8
|
|
|
9
9
|
The **single default Node implementation** of the Contracts mechanism protocol. This is a thin runtime: it only implements the mechanism protocol, introduces no business semantics, and does not provide a second-language implementation.
|
|
10
10
|
|
|
11
11
|
<!-- release-skill:managed:start id=latest-release -->
|
|
12
|
-
**0.
|
|
12
|
+
**0.5.0** (2026-08-16)
|
|
13
13
|
|
|
14
|
-
This release adds
|
|
14
|
+
This release adds the declared read surface assertion (FND-ADR-010) and the structured surface scanner (FND-ADR-011) to the stable Harness surface, growing HARNESS_CAPABILITIES from 16 to 18 entries, and adds three dependency-reviewed runtime libraries.
|
|
15
15
|
|
|
16
16
|
**Added**
|
|
17
17
|
|
|
18
|
-
- Adds
|
|
19
|
-
- Adds the
|
|
20
|
-
-
|
|
21
|
-
-
|
|
18
|
+
- Adds assertDeclaredReadSurface (FND-ADR-010): a no-execution, syntax-surface-only assertion that every node:fs named import inside a declared module set stays inside the consumer-declared read surface, with a closed violation vocabulary and a frozen declared-read-surface-result envelope.
|
|
19
|
+
- Adds scanSurfaceStructured (FND-ADR-011): the structured sibling of scanSurface. IP-shaped candidates enter a single standard parse entry (ipaddr.js) with consumer-declared CIDR approval and fail closed when unparseable; scoped and unscoped coordinates, registries and hosts need consumer-declared approval; format adapters (pnpm-lockfile via @pnpm/lockfile.fs with yaml AST comment regions, and tree-json) parse structurally with no position-level exemptions; binary and symlink policies fail closed. The closed nine-rule vocabulary travels in details.rule of the structured-scan-violation mechanism error.
|
|
20
|
+
- Adds three dependency-reviewed runtime libraries for the structured scanner, reviewed per FND-ADR-006 section-4 analogy (dependency-closure pre-review, executor self-review pending independent review): ipaddr.js 2.5.0 (MIT, zero dependencies), yaml 2.9.0 (ISC), and @pnpm/lockfile.fs 1001.1.35 (MIT, 18 transitive dependencies all inside the approved public coordinates).
|
|
21
|
+
- Grows HARNESS_CAPABILITIES from 16 to 18 entries for the two new mechanisms.
|
|
22
22
|
|
|
23
23
|
**Changed**
|
|
24
24
|
|
|
25
|
-
-
|
|
26
|
-
-
|
|
25
|
+
- Documents the relationship of the two policy documents to workspace-private leak policies: a workspace-private leak-policy.json instance document is not a subset, not isomorphic and not a migration target of the surface-scan-policy or structured-scan-policy schemas — the documents share rule vocabulary and fail-closed semantics by design, but their byte-level shapes are independent and must not be compared for compatibility. scanSurface is the execution-core generalization projection: the public, consumer-parameterized form of the same mechanism family, without any private identity, path, or approval-list interpretation of its own.
|
|
26
|
+
- Records the cost of the dependency-review decision honestly: the harness tarball grows with the @pnpm/lockfile.fs closure, the thin-runtime property changes from zero third-party runtime dependencies to three reviewed ones, and the pnpm-lockfile adapter writes one temporary lockfile copy under the OS temp directory (removed afterwards). The engineering-kit offline-consumer verification gates now derive the complete third-party production closure of the three Foundation packages mechanically (identity-deduplicated, npm: alias-aware, range-scoped override selectors) instead of a single-package closure, so the review decision is continuously verified against the real installed bytes.
|
|
27
|
+
- Keeps mechanism purity: no execution of scanned files, no model calls, no network; symlinked entries are never followed.
|
|
27
28
|
|
|
28
29
|
**Upgrade Notes**
|
|
29
30
|
|
|
30
|
-
Version 0.
|
|
31
|
+
Version 0.5.0 is the FND-ADR-010/011 harness line. Mechanism imports use the stable capability names published in HARNESS_CAPABILITIES; structured-scan policies must pass the structured-scan-policy contract validation of contract-spec 1.5.0.
|
|
31
32
|
<!-- release-skill:managed:end id=latest-release -->
|
|
32
33
|
|
|
33
34
|
## Problem It Solves
|
|
@@ -41,14 +42,14 @@ The Harness consumes `skill-family-contracts` (a workspace dependency), reusing
|
|
|
41
42
|
## Installation and Minimal Example
|
|
42
43
|
|
|
43
44
|
```sh
|
|
44
|
-
npm install skill-family-harness-node@0.
|
|
45
|
+
npm install skill-family-harness-node@0.5.0
|
|
45
46
|
npm info skill-family-harness-node --help
|
|
46
47
|
```
|
|
47
48
|
|
|
48
49
|
The minimal example shows validating a contract document inside Node:
|
|
49
50
|
|
|
50
51
|
```js
|
|
51
|
-
// Run from an empty directory: npm install skill-family-harness-node@0.
|
|
52
|
+
// Run from an empty directory: npm install skill-family-harness-node@0.5.0
|
|
52
53
|
import { validateContractDocument } from "skill-family-harness-node";
|
|
53
54
|
|
|
54
55
|
const document = {
|
package/README.zh-CN.md
CHANGED
|
@@ -5,30 +5,31 @@
|
|
|
5
5
|
|
|
6
6
|
# skill-family-harness-node
|
|
7
7
|
|
|
8
|
-
<!-- release-skill:release-version: 0.
|
|
8
|
+
<!-- release-skill:release-version: 0.5.0 -->
|
|
9
9
|
|
|
10
10
|
Contracts 机制协议的**唯一默认 Node 实现**。这是一个薄运行时(thin runtime):只实现机制协议,不引入业务语义,不做第二语言实现。
|
|
11
11
|
|
|
12
12
|
<!-- release-skill:managed:start id=latest-release -->
|
|
13
|
-
**0.
|
|
13
|
+
**0.5.0** (2026-08-16)
|
|
14
14
|
|
|
15
|
-
本版在稳定 Harness
|
|
15
|
+
本版在稳定 Harness 面上新增声明读取表面断言(FND-ADR-010)与结构化表面扫描器(FND-ADR-011),HARNESS_CAPABILITIES 从 16 项增至 18 项,并引入三个经依赖评审的运行时库。
|
|
16
16
|
|
|
17
17
|
**新增**
|
|
18
18
|
|
|
19
|
-
-
|
|
20
|
-
- 新增
|
|
21
|
-
-
|
|
22
|
-
-
|
|
19
|
+
- 新增 assertDeclaredReadSurface(FND-ADR-010):不执行、仅语法面的断言,声明模块集内的每个 node:fs 具名导入必须落在消费者声明的读取表面内,违规词汇表为闭集,返回冻结的 declared-read-surface-result 信封。
|
|
20
|
+
- 新增 scanSurfaceStructured(FND-ADR-011):scanSurface 的结构化兄弟。IP 形候选统一进入单一标准解析入口(ipaddr.js),按消费者声明的 CIDR 批准,不可解析即失败关闭;坐标(scoped 与非 scoped)、注册表与主机均需消费者声明批准;格式适配器(pnpm-lockfile 经 @pnpm/lockfile.fs + yaml AST 注释区域,tree-json 经 JSON.parse)结构化解析且无位置级豁免;二进制与符号链接策略失败关闭。闭集九规则词汇表经 structured-scan-violation 机制错误的 details.rule 承载。
|
|
21
|
+
- 为结构化扫描器引入三个经依赖评审的运行时库,评审按 FND-ADR-006 第 4 节类比执行(依赖闭包预审,执行者自审,独立复核另行安排):ipaddr.js 2.5.0(MIT,零依赖)、yaml 2.9.0(ISC)、@pnpm/lockfile.fs 1001.1.35(MIT,18 个传递依赖全部在批准公共坐标内)。
|
|
22
|
+
- HARNESS_CAPABILITIES 从 16 项增至 18 项,覆盖两个新机制。
|
|
23
23
|
|
|
24
24
|
**变更**
|
|
25
25
|
|
|
26
|
-
-
|
|
27
|
-
-
|
|
26
|
+
- 显式说明两个策略文档与工作区私有 leak 策略的关系:工作区私有的 leak-policy.json 实例文档既不是 surface-scan-policy 或 structured-scan-policy schema 的子集、也不同构、更不是迁移目标——两类文档按设计共享规则词汇与失败关闭语义,但字节级形状相互独立,不得比较兼容性。scanSurface 是执行内核通用化投影:同一机制族的公开、消费者参数化形态,自身不解释任何私有身份、路径或批准清单。
|
|
27
|
+
- 如实记录依赖评审决策的成本:harness tarball 随 @pnpm/lockfile.fs 闭包扩大;薄运行时属性从零第三方运行时依赖变为三个经评审依赖;pnpm-lockfile 适配器会在 OS 临时目录写一份临时锁文件副本(用后即删)。engineering-kit 的离线消费者验证门随之把第三方闭包推导从单包闭包机械扩展为三个 Foundation 包的完整生产闭包(真实身份去重、npm: 别名感知、range-scoped override selector),使评审决策持续对着真实安装字节被验证。
|
|
28
|
+
- 保持机制纯度:不执行被扫描文件、无模型调用、无网络;永不跟随符号链接。
|
|
28
29
|
|
|
29
30
|
**升级说明**
|
|
30
31
|
|
|
31
|
-
0.
|
|
32
|
+
0.5.0 是 FND-ADR-010/011 harness 线。机制导入使用 HARNESS_CAPABILITIES 公布的稳定能力名;structured-scan 策略必须通过契约规格 1.5.0 的 structured-scan-policy 契约校验。
|
|
32
33
|
<!-- release-skill:managed:end id=latest-release -->
|
|
33
34
|
|
|
34
35
|
## 解决的问题
|
|
@@ -42,14 +43,14 @@ Harness 消费 `skill-family-contracts`(工作区依赖),复用其方言
|
|
|
42
43
|
## 安装和最小示例
|
|
43
44
|
|
|
44
45
|
```sh
|
|
45
|
-
npm install skill-family-harness-node@0.
|
|
46
|
+
npm install skill-family-harness-node@0.5.0
|
|
46
47
|
npm info skill-family-harness-node --help
|
|
47
48
|
```
|
|
48
49
|
|
|
49
50
|
最小示例演示在 Node 内校验一份契约文档:
|
|
50
51
|
|
|
51
52
|
```js
|
|
52
|
-
// 从空目录运行:npm install skill-family-harness-node@0.
|
|
53
|
+
// 从空目录运行:npm install skill-family-harness-node@0.5.0
|
|
53
54
|
import { validateContractDocument } from "skill-family-harness-node";
|
|
54
55
|
|
|
55
56
|
const document = {
|
package/package.json
CHANGED
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
"url": "https://github.com/ifoohoo/skill-family-harness-node/issues"
|
|
6
6
|
},
|
|
7
7
|
"dependencies": {
|
|
8
|
-
"
|
|
8
|
+
"@pnpm/lockfile.fs": "1001.1.35",
|
|
9
|
+
"ipaddr.js": "2.5.0",
|
|
10
|
+
"skill-family-contracts": "0.5.0",
|
|
11
|
+
"yaml": "2.9.0"
|
|
9
12
|
},
|
|
10
13
|
"description": "Thin Node.js mechanism runtime for Skill Family engineering contracts.",
|
|
11
14
|
"engines": {
|
|
@@ -39,7 +42,7 @@
|
|
|
39
42
|
"url": "https://github.com/ifoohoo/skill-family-harness-node.git"
|
|
40
43
|
},
|
|
41
44
|
"type": "module",
|
|
42
|
-
"version": "0.
|
|
45
|
+
"version": "0.5.0",
|
|
43
46
|
"scripts": {
|
|
44
47
|
"check": "node --test",
|
|
45
48
|
"test": "node --test"
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
version: 0.5.0
|
|
2
|
+
date: 2026-08-16
|
|
3
|
+
locales:
|
|
4
|
+
en:
|
|
5
|
+
summary: This release adds the declared read surface assertion (FND-ADR-010) and the structured surface scanner (FND-ADR-011) to the stable Harness surface, growing HARNESS_CAPABILITIES from 16 to 18 entries, and adds three dependency-reviewed runtime libraries.
|
|
6
|
+
changes:
|
|
7
|
+
added:
|
|
8
|
+
- "Adds assertDeclaredReadSurface (FND-ADR-010): a no-execution, syntax-surface-only assertion that every node:fs named import inside a declared module set stays inside the consumer-declared read surface, with a closed violation vocabulary and a frozen declared-read-surface-result envelope."
|
|
9
|
+
- "Adds scanSurfaceStructured (FND-ADR-011): the structured sibling of scanSurface. IP-shaped candidates enter a single standard parse entry (ipaddr.js) with consumer-declared CIDR approval and fail closed when unparseable; scoped and unscoped coordinates, registries and hosts need consumer-declared approval; format adapters (pnpm-lockfile via @pnpm/lockfile.fs with yaml AST comment regions, and tree-json) parse structurally with no position-level exemptions; binary and symlink policies fail closed. The closed nine-rule vocabulary travels in details.rule of the structured-scan-violation mechanism error."
|
|
10
|
+
- "Adds three dependency-reviewed runtime libraries for the structured scanner, reviewed per FND-ADR-006 section-4 analogy (dependency-closure pre-review, executor self-review pending independent review): ipaddr.js 2.5.0 (MIT, zero dependencies), yaml 2.9.0 (ISC), and @pnpm/lockfile.fs 1001.1.35 (MIT, 18 transitive dependencies all inside the approved public coordinates)."
|
|
11
|
+
- Grows HARNESS_CAPABILITIES from 16 to 18 entries for the two new mechanisms.
|
|
12
|
+
changed:
|
|
13
|
+
- "Documents the relationship of the two policy documents to workspace-private leak policies: a workspace-private leak-policy.json instance document is not a subset, not isomorphic and not a migration target of the surface-scan-policy or structured-scan-policy schemas — the documents share rule vocabulary and fail-closed semantics by design, but their byte-level shapes are independent and must not be compared for compatibility. scanSurface is the execution-core generalization projection: the public, consumer-parameterized form of the same mechanism family, without any private identity, path, or approval-list interpretation of its own."
|
|
14
|
+
- "Records the cost of the dependency-review decision honestly: the harness tarball grows with the @pnpm/lockfile.fs closure, the thin-runtime property changes from zero third-party runtime dependencies to three reviewed ones, and the pnpm-lockfile adapter writes one temporary lockfile copy under the OS temp directory (removed afterwards). The engineering-kit offline-consumer verification gates now derive the complete third-party production closure of the three Foundation packages mechanically (identity-deduplicated, npm: alias-aware, range-scoped override selectors) instead of a single-package closure, so the review decision is continuously verified against the real installed bytes."
|
|
15
|
+
- "Keeps mechanism purity: no execution of scanned files, no model calls, no network; symlinked entries are never followed."
|
|
16
|
+
upgradeNotes: Version 0.5.0 is the FND-ADR-010/011 harness line. Mechanism imports use the stable capability names published in HARNESS_CAPABILITIES; structured-scan policies must pass the structured-scan-policy contract validation of contract-spec 1.5.0.
|
|
17
|
+
zh-CN:
|
|
18
|
+
summary: 本版在稳定 Harness 面上新增声明读取表面断言(FND-ADR-010)与结构化表面扫描器(FND-ADR-011),HARNESS_CAPABILITIES 从 16 项增至 18 项,并引入三个经依赖评审的运行时库。
|
|
19
|
+
changes:
|
|
20
|
+
added:
|
|
21
|
+
- 新增 assertDeclaredReadSurface(FND-ADR-010):不执行、仅语法面的断言,声明模块集内的每个 node:fs 具名导入必须落在消费者声明的读取表面内,违规词汇表为闭集,返回冻结的 declared-read-surface-result 信封。
|
|
22
|
+
- 新增 scanSurfaceStructured(FND-ADR-011):scanSurface 的结构化兄弟。IP 形候选统一进入单一标准解析入口(ipaddr.js),按消费者声明的 CIDR 批准,不可解析即失败关闭;坐标(scoped 与非 scoped)、注册表与主机均需消费者声明批准;格式适配器(pnpm-lockfile 经 @pnpm/lockfile.fs + yaml AST 注释区域,tree-json 经 JSON.parse)结构化解析且无位置级豁免;二进制与符号链接策略失败关闭。闭集九规则词汇表经 structured-scan-violation 机制错误的 details.rule 承载。
|
|
23
|
+
- 为结构化扫描器引入三个经依赖评审的运行时库,评审按 FND-ADR-006 第 4 节类比执行(依赖闭包预审,执行者自审,独立复核另行安排):ipaddr.js 2.5.0(MIT,零依赖)、yaml 2.9.0(ISC)、@pnpm/lockfile.fs 1001.1.35(MIT,18 个传递依赖全部在批准公共坐标内)。
|
|
24
|
+
- HARNESS_CAPABILITIES 从 16 项增至 18 项,覆盖两个新机制。
|
|
25
|
+
changed:
|
|
26
|
+
- 显式说明两个策略文档与工作区私有 leak 策略的关系:工作区私有的 leak-policy.json 实例文档既不是 surface-scan-policy 或 structured-scan-policy schema 的子集、也不同构、更不是迁移目标——两类文档按设计共享规则词汇与失败关闭语义,但字节级形状相互独立,不得比较兼容性。scanSurface 是执行内核通用化投影:同一机制族的公开、消费者参数化形态,自身不解释任何私有身份、路径或批准清单。
|
|
27
|
+
- "如实记录依赖评审决策的成本:harness tarball 随 @pnpm/lockfile.fs 闭包扩大;薄运行时属性从零第三方运行时依赖变为三个经评审依赖;pnpm-lockfile 适配器会在 OS 临时目录写一份临时锁文件副本(用后即删)。engineering-kit 的离线消费者验证门随之把第三方闭包推导从单包闭包机械扩展为三个 Foundation 包的完整生产闭包(真实身份去重、npm: 别名感知、range-scoped override selector),使评审决策持续对着真实安装字节被验证。"
|
|
28
|
+
- 保持机制纯度:不执行被扫描文件、无模型调用、无网络;永不跟随符号链接。
|
|
29
|
+
upgradeNotes: 0.5.0 是 FND-ADR-010/011 harness 线。机制导入使用 HARNESS_CAPABILITIES 公布的稳定能力名;structured-scan 策略必须通过契约规格 1.5.0 的 structured-scan-policy 契约校验。
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
3
|
+
import { HARNESS_ERROR_KINDS, mechanismError } from "./errors.mjs";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Static declared read surface verification (FND-ADR-010).
|
|
7
|
+
*
|
|
8
|
+
* assertDeclaredReadSurface({ root, declaredReaders }) checks, by static
|
|
9
|
+
* source analysis, that every `.mjs`/`.js` module under `root` only reads
|
|
10
|
+
* node:fs through the consumer-declared reader set:
|
|
11
|
+
*
|
|
12
|
+
* - a module that imports named APIs from "node:fs" without being declared
|
|
13
|
+
* violates rule `undeclared-module-imports-fs`;
|
|
14
|
+
* - a declared module importing an API outside its declared set violates
|
|
15
|
+
* rule `fs-api-outside-declared-set`;
|
|
16
|
+
* - any write-family fs API name appearing anywhere in the tree (whole
|
|
17
|
+
* source substring detection, including comments and string literals)
|
|
18
|
+
* violates rule `write-family-fs-api` (conservative approximation: static
|
|
19
|
+
* syntax-level violations are never under-reported).
|
|
20
|
+
*
|
|
21
|
+
* The verdict semantics mirror the audit tree's P3 check one-to-one; the
|
|
22
|
+
* result is shaped as the contracts `declared-read-surface-result` envelope
|
|
23
|
+
* whose scope, rule enumeration and guarantees are the machine contract.
|
|
24
|
+
* Input is fully parameterized: root and declaredReaders are consumer data,
|
|
25
|
+
* the mechanism knows no audit paths, no workspace layout, and no business
|
|
26
|
+
* meaning of any module.
|
|
27
|
+
*
|
|
28
|
+
* Violations are collected as an evidence list (deterministic order:
|
|
29
|
+
* sorted module list, then rule order per module) rather than stopping at
|
|
30
|
+
* the first hit; `ok` is false exactly when the list is non-empty.
|
|
31
|
+
*
|
|
32
|
+
* Mechanism purity: read-only (never writes, never executes the scanned
|
|
33
|
+
* modules, no model call, no network); symlinked entries under the root are
|
|
34
|
+
* never followed and never scanned (conservative: a symlink may not smuggle
|
|
35
|
+
* a module out of the declared surface).
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const MODULE_EXTENSIONS = [".mjs", ".js"];
|
|
39
|
+
const FS_NAMED_IMPORT_PATTERN = /import\s*\{([^}]*)\}\s*from\s*"node:fs"/g;
|
|
40
|
+
|
|
41
|
+
// Write-family API names are built by composition so this module's own
|
|
42
|
+
// source never contains the full forbidden names as literals (self-hit
|
|
43
|
+
// hygiene, inherited from the audit tree's P3 check). Detection of scanned
|
|
44
|
+
// modules is unaffected: they would spell the full names.
|
|
45
|
+
const WRITE_FS_VERBS = ["writeFile", "appendFile", "mkdir", "rm", "unlink", "rename", "copyFile"];
|
|
46
|
+
const WRITE_FS_APIS = WRITE_FS_VERBS.map((verb) => `${verb}Sync`).concat([
|
|
47
|
+
`${"createWrite"}Stream`,
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
export const DECLARED_READ_SURFACE_RULES = Object.freeze({
|
|
51
|
+
UNDECLARED_MODULE_IMPORTS_FS: "undeclared-module-imports-fs",
|
|
52
|
+
FS_API_OUTSIDE_DECLARED_SET: "fs-api-outside-declared-set",
|
|
53
|
+
WRITE_FAMILY_FS_API: "write-family-fs-api",
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const RESULT_GUARANTEES = Object.freeze([
|
|
57
|
+
"syntax-surface-only",
|
|
58
|
+
"conservative-approximation",
|
|
59
|
+
"no-execution",
|
|
60
|
+
"no-model-calls",
|
|
61
|
+
"no-network-access",
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
function invalidInput(reason, extra) {
|
|
65
|
+
return mechanismError(
|
|
66
|
+
HARNESS_ERROR_KINDS.DECLARED_READ_SURFACE_INVALID,
|
|
67
|
+
`assertDeclaredReadSurface: ${reason}`,
|
|
68
|
+
extra,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function assertPlainObject(value, label) {
|
|
73
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
74
|
+
throw invalidInput(`${label} must be a plain object`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isValidDeclaredModulePath(modulePath) {
|
|
79
|
+
if (typeof modulePath !== "string" || modulePath.length === 0) return false;
|
|
80
|
+
if (modulePath.includes("\0")) return false;
|
|
81
|
+
if (modulePath.includes("\\")) return false;
|
|
82
|
+
if (path.posix.isAbsolute(modulePath)) return false;
|
|
83
|
+
if (/^[A-Za-z]:/.test(modulePath)) return false;
|
|
84
|
+
const normalized = path.posix.normalize(modulePath);
|
|
85
|
+
if (normalized === "." || normalized === "..") return false;
|
|
86
|
+
if (normalized.split("/").some((segment) => segment === "..")) return false;
|
|
87
|
+
return normalized === modulePath;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function collectModules(root) {
|
|
91
|
+
const modules = [];
|
|
92
|
+
const walk = (directory) => {
|
|
93
|
+
const entries = readdirSync(directory, { withFileTypes: true }).sort((a, b) =>
|
|
94
|
+
a.name.localeCompare(b.name),
|
|
95
|
+
);
|
|
96
|
+
for (const entry of entries) {
|
|
97
|
+
if (entry.isSymbolicLink()) continue; // never followed, never scanned
|
|
98
|
+
const fullPath = path.join(directory, entry.name);
|
|
99
|
+
if (entry.isDirectory()) {
|
|
100
|
+
walk(fullPath);
|
|
101
|
+
} else if (entry.isFile() && MODULE_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) {
|
|
102
|
+
modules.push(fullPath);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
walk(root);
|
|
107
|
+
return modules;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function namedFsImports(source) {
|
|
111
|
+
return [...source.matchAll(FS_NAMED_IMPORT_PATTERN)].flatMap((match) =>
|
|
112
|
+
match[1].split(",").map((api) => api.trim()).filter(Boolean),
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Static declared read surface verification. Returns the contracts
|
|
118
|
+
* declared-read-surface-result envelope (frozen). Invalid inputs (non-empty
|
|
119
|
+
* root that is not a directory, empty declaredReaders, illegal module path
|
|
120
|
+
* shapes, malformed declared sets) throw SFC2004 with details.kind
|
|
121
|
+
* declared-read-surface-invalid; a detected violation is reported in the
|
|
122
|
+
* result's violations list (ok: false), never as an exception.
|
|
123
|
+
*/
|
|
124
|
+
export function assertDeclaredReadSurface({ root, declaredReaders } = {}) {
|
|
125
|
+
if (typeof root !== "string" || root.length === 0) {
|
|
126
|
+
throw invalidInput("root must be a non-empty path");
|
|
127
|
+
}
|
|
128
|
+
assertPlainObject(declaredReaders, "declaredReaders");
|
|
129
|
+
const readerKeys = Object.keys(declaredReaders);
|
|
130
|
+
if (readerKeys.length === 0) {
|
|
131
|
+
throw invalidInput("declaredReaders must declare at least one reader module");
|
|
132
|
+
}
|
|
133
|
+
const declared = new Map();
|
|
134
|
+
for (const key of readerKeys) {
|
|
135
|
+
if (!isValidDeclaredModulePath(key)) {
|
|
136
|
+
throw invalidInput(`declared reader module path has an illegal shape: ${JSON.stringify(key)}`);
|
|
137
|
+
}
|
|
138
|
+
const apis = declaredReaders[key];
|
|
139
|
+
if (!Array.isArray(apis)) {
|
|
140
|
+
throw invalidInput(`declared set for ${key} must be an array of fs API names`);
|
|
141
|
+
}
|
|
142
|
+
for (const api of apis) {
|
|
143
|
+
if (typeof api !== "string" || api.length === 0) {
|
|
144
|
+
throw invalidInput(`declared set for ${key} contains a malformed fs API name`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
declared.set(key, apis);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const rootResolved = path.resolve(root);
|
|
151
|
+
let rootStat;
|
|
152
|
+
try {
|
|
153
|
+
rootStat = statSync(rootResolved);
|
|
154
|
+
} catch {
|
|
155
|
+
throw invalidInput("root does not exist or cannot be inspected", { root: "<opaque>" });
|
|
156
|
+
}
|
|
157
|
+
if (!rootStat.isDirectory()) {
|
|
158
|
+
throw invalidInput("root is not a directory", { root: "<opaque>" });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const modules = collectModules(rootResolved);
|
|
162
|
+
const violations = [];
|
|
163
|
+
const pushViolation = (entry) => violations.push(entry);
|
|
164
|
+
|
|
165
|
+
for (const module of modules) {
|
|
166
|
+
const relative = path.relative(rootResolved, module).split(path.sep).join("/");
|
|
167
|
+
const source = readFileSync(module, "utf8");
|
|
168
|
+
const fsImports = namedFsImports(source);
|
|
169
|
+
const allowed = declared.get(relative);
|
|
170
|
+
if (fsImports.length > 0 && allowed === undefined) {
|
|
171
|
+
pushViolation({ module: relative, rule: DECLARED_READ_SURFACE_RULES.UNDECLARED_MODULE_IMPORTS_FS });
|
|
172
|
+
}
|
|
173
|
+
if (allowed !== undefined) {
|
|
174
|
+
for (const api of fsImports) {
|
|
175
|
+
if (!allowed.includes(api)) {
|
|
176
|
+
pushViolation({ module: relative, api, rule: DECLARED_READ_SURFACE_RULES.FS_API_OUTSIDE_DECLARED_SET });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
for (const api of WRITE_FS_APIS) {
|
|
181
|
+
if (source.includes(api)) {
|
|
182
|
+
pushViolation({ module: relative, api, rule: DECLARED_READ_SURFACE_RULES.WRITE_FAMILY_FS_API });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return Object.freeze({
|
|
188
|
+
schemaVersion: 1,
|
|
189
|
+
kind: "skill-family.declared-read-surface-result",
|
|
190
|
+
ok: violations.length === 0,
|
|
191
|
+
violations,
|
|
192
|
+
scannedModules: modules.map((module) =>
|
|
193
|
+
path.relative(rootResolved, module).split(path.sep).join("/"),
|
|
194
|
+
),
|
|
195
|
+
scope: "esm-named-imports-only",
|
|
196
|
+
guarantees: [...RESULT_GUARANTEES],
|
|
197
|
+
});
|
|
198
|
+
}
|
package/src/errors.mjs
CHANGED
|
@@ -65,6 +65,10 @@ export const HARNESS_ERROR_KINDS = Object.freeze({
|
|
|
65
65
|
READ_CHOKEPOINT_REJECTED: "read-chokepoint-rejected",
|
|
66
66
|
SURFACE_SCAN_VIOLATION: "surface-scan-violation",
|
|
67
67
|
SCAN_POLICY_INVALID: "scan-policy-invalid",
|
|
68
|
+
DECLARED_READ_SURFACE_VIOLATION: "declared-read-surface-violation",
|
|
69
|
+
DECLARED_READ_SURFACE_INVALID: "declared-read-surface-invalid",
|
|
70
|
+
STRUCTURED_SCAN_VIOLATION: "structured-scan-violation",
|
|
71
|
+
STRUCTURED_SCAN_INVALID: "structured-scan-invalid",
|
|
68
72
|
UPPER_BOUND_EXCEEDED: "upper-bound-exceeded",
|
|
69
73
|
});
|
|
70
74
|
|
package/src/index.mjs
CHANGED
|
@@ -25,6 +25,8 @@ export const HARNESS_CAPABILITIES = Object.freeze([
|
|
|
25
25
|
"baseline-materialization",
|
|
26
26
|
"read-chokepoint",
|
|
27
27
|
"surface-scan",
|
|
28
|
+
"declared-read-surface",
|
|
29
|
+
"structured-scan",
|
|
28
30
|
"token-estimation",
|
|
29
31
|
"upper-bound-guard",
|
|
30
32
|
]);
|
|
@@ -66,6 +68,10 @@ export { createReadChokepoint } from "./chokepoint.mjs";
|
|
|
66
68
|
|
|
67
69
|
export { scanSurface } from "./surface-scan.mjs";
|
|
68
70
|
|
|
71
|
+
export { assertDeclaredReadSurface } from "./declared-read-surface.mjs";
|
|
72
|
+
|
|
73
|
+
export { scanSurfaceStructured, STRUCTURED_SCAN_RULES, isIpv6ShapedRun, extractIpCandidates, normalizeIpToken, classifyIpToken, collectLockfileCommentRegions } from "./structured-scan.mjs";
|
|
74
|
+
|
|
69
75
|
export { estimateTokenUpperBound } from "./token-estimate.mjs";
|
|
70
76
|
|
|
71
77
|
export {
|
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
import { findSchemaByObject } from "skill-family-contracts";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync, lstatSync, readlinkSync } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import ipaddr from "ipaddr.js";
|
|
6
|
+
import { readWantedLockfile } from "@pnpm/lockfile.fs";
|
|
7
|
+
import { parseDocument, visit } from "yaml";
|
|
8
|
+
import { HARNESS_ERROR_KINDS, mechanismError } from "./errors.mjs";
|
|
9
|
+
import { classifyPathInput, readFileContained, resolveContained } from "./paths.mjs";
|
|
10
|
+
import { validateContractDocument } from "./validation.mjs";
|
|
11
|
+
|
|
12
|
+
const POLICY_SCHEMA_ID = findSchemaByObject("structured-scan-policy").$id;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Structured surface scanner (FND-ADR-011).
|
|
16
|
+
*
|
|
17
|
+
* scanSurfaceStructured({ root, relPaths, policy }) is the public,
|
|
18
|
+
* consumer-parameterized form of the same mechanism family as scanSurface:
|
|
19
|
+
* it scans consumer-declared files under a consumer-declared root against a
|
|
20
|
+
* consumer-declared structured-scan-policy contract document, and fails
|
|
21
|
+
* closed on the first hit (structured-scan-violation with details.rule from
|
|
22
|
+
* the closed vocabulary). The mechanism owns no private identity, no
|
|
23
|
+
* workspace layout, no approval list: every network, registry, coordinate,
|
|
24
|
+
* adapter, host-key pattern and binary approval is consumer data.
|
|
25
|
+
*
|
|
26
|
+
* Closed rule vocabulary (details.rule):
|
|
27
|
+
* non-public-address CIDR-outside or unparseable IP-shaped token
|
|
28
|
+
* private-npm-scope scoped coordinate outside approvedCoordinates
|
|
29
|
+
* unapproved-dependency-coordinate unscoped coordinate outside approvedCoordinates
|
|
30
|
+
* non-approved-registry URL reference outside approvedRegistries
|
|
31
|
+
* non-reserved-hostname host that is not localhost, not an approved
|
|
32
|
+
* registry host, and not hostKeyPattern-shaped
|
|
33
|
+
* format-adapter-parse-failed a registered adapter could not parse the file
|
|
34
|
+
* format-adapter-unknown a registered adapter name is not implemented
|
|
35
|
+
* binary-unclassified non-text file outside binaryPolicy.approvedPaths
|
|
36
|
+
* symlink-forbidden symlinked entry (readlink records the target
|
|
37
|
+
* text; the target is never followed)
|
|
38
|
+
*
|
|
39
|
+
* Address semantics: lexical candidate boundaries (rules (A)-(E) for
|
|
40
|
+
* IPv6-shaped runs plus dotted-decimal IPv4 shape) feed a single standard
|
|
41
|
+
* parse entry (ipaddr.js); an unparseable candidate fails closed
|
|
42
|
+
* (non-public-address) — no undocumented syntax exemption exists. IPv4-mapped
|
|
43
|
+
* and compatible embedded addresses delegate to the embedded IPv4's CIDR
|
|
44
|
+
* classification. Bias direction is declared in the policy contract:
|
|
45
|
+
* conservative, never silently skipped.
|
|
46
|
+
*
|
|
47
|
+
* Format adapters: files whose relative path matches a registered
|
|
48
|
+
* formatAdapters[].pathPattern are parsed structurally; a parse failure is
|
|
49
|
+
* format-adapter-parse-failed (fail-closed) and an unregistered adapter name
|
|
50
|
+
* is format-adapter-unknown (fail-closed). There is no position-level whole
|
|
51
|
+
* key or whole file exemption: every key and every string leaf value of a
|
|
52
|
+
* parsed document still runs the full structure rules. Built-in adapters:
|
|
53
|
+
* "pnpm-lockfile" (@pnpm/lockfile.fs semantic object plus yaml AST comment
|
|
54
|
+
* regions) and "tree-json" (JSON.parse).
|
|
55
|
+
*
|
|
56
|
+
* Mechanism purity: never executes scanned files, no model call, no network;
|
|
57
|
+
* the only write is a temporary lockfile copy under the OS temp directory
|
|
58
|
+
* (cleaned up) for the pnpm-lockfile adapter. Symlinked entries are never
|
|
59
|
+
* followed.
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
export const STRUCTURED_SCAN_RULES = Object.freeze({
|
|
63
|
+
NON_PUBLIC_ADDRESS: "non-public-address",
|
|
64
|
+
PRIVATE_NPM_SCOPE: "private-npm-scope",
|
|
65
|
+
UNAPPROVED_DEPENDENCY_COORDINATE: "unapproved-dependency-coordinate",
|
|
66
|
+
NON_APPROVED_REGISTRY: "non-approved-registry",
|
|
67
|
+
NON_RESERVED_HOSTNAME: "non-reserved-hostname",
|
|
68
|
+
FORMAT_ADAPTER_PARSE_FAILED: "format-adapter-parse-failed",
|
|
69
|
+
FORMAT_ADAPTER_UNKNOWN: "format-adapter-unknown",
|
|
70
|
+
BINARY_UNCLASSIFIED: "binary-unclassified",
|
|
71
|
+
SYMLINK_FORBIDDEN: "symlink-forbidden",
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------- patterns
|
|
75
|
+
|
|
76
|
+
const URL_PATTERN = /[A-Za-z][A-Za-z0-9+.-]*:\/\/(\[[^\]/\s]+\]|[A-Za-z0-9][A-Za-z0-9-]*(?:\.[A-Za-z0-9-]+)+[^/\s"'<>\\:]*)/g;
|
|
77
|
+
const EMAIL_PATTERN = /[A-Za-z0-9._%+-]+@([A-Za-z0-9][A-Za-z0-9.-]*\.[A-Za-z]{2,})/g;
|
|
78
|
+
const HOST_KEY_STRING_PATTERN = /"(?:host|hostname|server|endpoint)"\s*:\s*"([^"]+)"/g;
|
|
79
|
+
const HOST_KEY_ARRAY_PATTERN = /"hosts"\s*:\s*\[([^\]]*)\]/g;
|
|
80
|
+
const QUOTED_TOKEN_PATTERN = /"([^"]+)"/g;
|
|
81
|
+
const NPM_SCOPE_PATTERN = /(?<![A-Za-z0-9._%+-])@([a-z0-9][a-z0-9._-]*)\/([a-z0-9][a-z0-9._-]*)/g;
|
|
82
|
+
const IP_RUN_PATTERN = /[0-9A-Za-z:.]+(?:%[0-9A-Za-z._~-]+)?/g;
|
|
83
|
+
const IPV4_SHAPED_PATTERN = /\d{1,3}(?:\.\d{1,3}){3}/g;
|
|
84
|
+
const HEX_GROUP_SEGMENT_PATTERN = /^[0-9A-Fa-f]{1,4}$/;
|
|
85
|
+
const ALPHA_SHORT_SEGMENT_PATTERN = /^[A-Za-z]{1,4}$/;
|
|
86
|
+
const HEX_LETTER_PATTERN = /[A-Fa-f]/;
|
|
87
|
+
const DECIMAL_DIGIT_PATTERN = /[0-9]/;
|
|
88
|
+
const LOCKFILE_URL_REFERENCE_PATTERN = /^(https?|git|git\+https|git\+ssh|ssh):/;
|
|
89
|
+
const LOCAL_LOCKFILE_REF_PREFIXES = ["link:", "file:", "path:", "workspace:"];
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Lexical boundary judgement: is a colon-containing run IP-shaped?
|
|
93
|
+
* Rules (A)-(E) are the public lexical boundary: (A) contains "::"; (B) at
|
|
94
|
+
* least four non-empty colon segments; (C) at least three segments and all
|
|
95
|
+
* non-empty segments are 1-4 hex digits with a hex letter present; (D) an
|
|
96
|
+
* empty segment exists, all non-empty segments are hex groups, at least two
|
|
97
|
+
* non-empty segments, with a hex letter present; (E) at least one non-empty
|
|
98
|
+
* segment is a 1-4 letter alpha run while a decimal digit exists elsewhere.
|
|
99
|
+
* Documented non-address classes (pure-decimal colon runs, pure-alpha colon
|
|
100
|
+
* runs, digest-prefix runs, single-group colon labels, semver/plain text)
|
|
101
|
+
* are kept clean by independent positive fixtures; everything that cannot be
|
|
102
|
+
* classified as non-address enters the single parse entry and fails closed
|
|
103
|
+
* when unparseable.
|
|
104
|
+
*/
|
|
105
|
+
export function isIpv6ShapedRun(run) {
|
|
106
|
+
if (!run.includes(":")) return false;
|
|
107
|
+
if (run.includes("::")) return true; // (A)
|
|
108
|
+
const segments = run.split(":");
|
|
109
|
+
const nonEmptySegments = segments.filter((segment) => segment.length > 0);
|
|
110
|
+
const hasEmptySegment = nonEmptySegments.length !== segments.length;
|
|
111
|
+
if (nonEmptySegments.length >= 4) return true; // (B)
|
|
112
|
+
const allHexGroups =
|
|
113
|
+
nonEmptySegments.length > 0 && nonEmptySegments.every((segment) => HEX_GROUP_SEGMENT_PATTERN.test(segment));
|
|
114
|
+
if (allHexGroups && nonEmptySegments.length >= 3 && HEX_LETTER_PATTERN.test(run)) return true; // (C)
|
|
115
|
+
if (hasEmptySegment && allHexGroups && nonEmptySegments.length >= 2 && HEX_LETTER_PATTERN.test(run)) return true; // (D)
|
|
116
|
+
if (nonEmptySegments.some((segment) => ALPHA_SHORT_SEGMENT_PATTERN.test(segment)) && DECIMAL_DIGIT_PATTERN.test(run)) return true; // (E)
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** IP-shaped candidate extraction. Sentence punctuation is stripped before
|
|
121
|
+
* the rules; IPv4 candidates not covered by a colon candidate are added. */
|
|
122
|
+
export function extractIpCandidates(text) {
|
|
123
|
+
const candidates = [];
|
|
124
|
+
for (const match of text.matchAll(IP_RUN_PATTERN)) {
|
|
125
|
+
let run = match[0];
|
|
126
|
+
let index = match.index;
|
|
127
|
+
const leadingDots = run.match(/^\.+/);
|
|
128
|
+
if (leadingDots) {
|
|
129
|
+
index += leadingDots[0].length;
|
|
130
|
+
run = run.slice(leadingDots[0].length);
|
|
131
|
+
}
|
|
132
|
+
const trailingDots = run.match(/\.+$/);
|
|
133
|
+
if (trailingDots) run = run.slice(0, run.length - trailingDots[0].length);
|
|
134
|
+
if (isIpv6ShapedRun(run)) {
|
|
135
|
+
candidates.push({ token: run, index, shape: "ipv6" });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
for (const match of text.matchAll(IPV4_SHAPED_PATTERN)) {
|
|
139
|
+
const coveredByColonCandidate = candidates.some(
|
|
140
|
+
(entry) => match.index >= entry.index && match.index + match[0].length <= entry.index + entry.token.length,
|
|
141
|
+
);
|
|
142
|
+
if (!coveredByColonCandidate) {
|
|
143
|
+
candidates.push({ token: match[0], index: match.index, shape: "ipv4" });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
candidates.sort((a, b) => a.index - b.index);
|
|
147
|
+
return candidates;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Normalizes a candidate for the parse entry: URL brackets and %zone-id. */
|
|
151
|
+
export function normalizeIpToken(token) {
|
|
152
|
+
let core = String(token);
|
|
153
|
+
if (core.startsWith("[")) {
|
|
154
|
+
const close = core.indexOf("]");
|
|
155
|
+
core = close === -1 ? core.slice(1) : core.slice(1, close);
|
|
156
|
+
}
|
|
157
|
+
const zone = core.indexOf("%");
|
|
158
|
+
if (zone !== -1) core = core.slice(0, zone);
|
|
159
|
+
return core;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function withinApprovedNetworks(cidrs, addr) {
|
|
163
|
+
for (const [cidrAddress, cidrRange] of cidrs) {
|
|
164
|
+
if (cidrAddress.kind() !== addr.kind()) continue; // ipaddr.match throws across kinds; classify by family
|
|
165
|
+
if (addr.match(cidrAddress, cidrRange)) return { allowed: true, reason: "approved-network" };
|
|
166
|
+
}
|
|
167
|
+
return { allowed: false, reason: "outside-approved-networks" };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function classifyIpv4Octets(cidrs, octets) {
|
|
171
|
+
return withinApprovedNetworks(cidrs, new ipaddr.IPv4(octets));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Single address classification entry: brackets/zone id/case/IPv4/IPv6 all
|
|
176
|
+
* pass through here. ipaddr.parse failure is fail-closed
|
|
177
|
+
* (unparseable-ip-shaped-token); IPv4-mapped/compatible embeddings delegate
|
|
178
|
+
* to the embedded IPv4's CIDR classification.
|
|
179
|
+
*/
|
|
180
|
+
export function classifyIpToken(cidrs, token) {
|
|
181
|
+
const core = normalizeIpToken(token);
|
|
182
|
+
let addr;
|
|
183
|
+
try {
|
|
184
|
+
addr = ipaddr.parse(core);
|
|
185
|
+
} catch {
|
|
186
|
+
return { allowed: false, reason: "unparseable-ip-shaped-token" };
|
|
187
|
+
}
|
|
188
|
+
if (addr.kind() === "ipv6") {
|
|
189
|
+
const bytes = addr.toByteArray();
|
|
190
|
+
const embedded =
|
|
191
|
+
bytes.slice(0, 10).every((byte) => byte === 0) &&
|
|
192
|
+
((bytes[10] === 0xff && bytes[11] === 0xff) || (bytes[10] === 0 && bytes[11] === 0));
|
|
193
|
+
if (embedded) {
|
|
194
|
+
const verdict = classifyIpv4Octets(cidrs, bytes.slice(12));
|
|
195
|
+
return { allowed: verdict.allowed, reason: `embedded-ipv4:${verdict.reason}` };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return withinApprovedNetworks(cidrs, addr);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function lineOf(bytes, index) {
|
|
202
|
+
let line = 1;
|
|
203
|
+
for (let i = 0; i < index && i < bytes.length; i += 1) {
|
|
204
|
+
if (bytes[i] === 0x0a) line += 1;
|
|
205
|
+
}
|
|
206
|
+
return line;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function isIpShapedToken(bare) {
|
|
210
|
+
if (isIpv6ShapedRun(bare)) return true;
|
|
211
|
+
return IPV4_SHAPED_PATTERN.test(bare);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function registryHosts(approvedRegistries) {
|
|
215
|
+
const hosts = new Set();
|
|
216
|
+
for (const registry of approvedRegistries) {
|
|
217
|
+
const match = registry.match(/^[a-z][a-z0-9+.-]*:\/\/([^/]+)/i);
|
|
218
|
+
if (match) {
|
|
219
|
+
const bare = match[1].toLowerCase().replace(/\.$/, "");
|
|
220
|
+
const portIndex = bare.indexOf(":");
|
|
221
|
+
hosts.add(portIndex === -1 ? bare : bare.slice(0, portIndex));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return hosts;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ----------------------------------------------------------- policy loading
|
|
228
|
+
|
|
229
|
+
function loadPolicy(policy) {
|
|
230
|
+
const checked = validateContractDocument(policy, { schemaId: POLICY_SCHEMA_ID });
|
|
231
|
+
if (!checked.valid) {
|
|
232
|
+
throw mechanismError(HARNESS_ERROR_KINDS.STRUCTURED_SCAN_INVALID, "structured-scan policy document is invalid", {
|
|
233
|
+
errors: checked.errors,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const cidrs = [];
|
|
238
|
+
for (const cidr of policy.allowedNetworks) {
|
|
239
|
+
let parsed;
|
|
240
|
+
try {
|
|
241
|
+
parsed = ipaddr.parseCIDR(cidr);
|
|
242
|
+
} catch (cause) {
|
|
243
|
+
throw mechanismError(
|
|
244
|
+
HARNESS_ERROR_KINDS.STRUCTURED_SCAN_INVALID,
|
|
245
|
+
`structured-scan policy contains an invalid CIDR: ${cidr}`,
|
|
246
|
+
{ cidr, cause: cause && cause.message ? cause.message : String(cause) },
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
cidrs.push(parsed);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
let hostKeyPattern = null;
|
|
253
|
+
if (typeof policy.hostKeyPattern === "string") {
|
|
254
|
+
try {
|
|
255
|
+
hostKeyPattern = new RegExp(policy.hostKeyPattern);
|
|
256
|
+
} catch (cause) {
|
|
257
|
+
throw mechanismError(
|
|
258
|
+
HARNESS_ERROR_KINDS.STRUCTURED_SCAN_INVALID,
|
|
259
|
+
"structured-scan policy hostKeyPattern does not compile",
|
|
260
|
+
{ cause: cause && cause.message ? cause.message : String(cause) },
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const adapters = [];
|
|
266
|
+
for (const adapter of policy.formatAdapters) {
|
|
267
|
+
let pattern;
|
|
268
|
+
try {
|
|
269
|
+
pattern = new RegExp(adapter.pathPattern);
|
|
270
|
+
} catch (cause) {
|
|
271
|
+
throw mechanismError(
|
|
272
|
+
HARNESS_ERROR_KINDS.STRUCTURED_SCAN_INVALID,
|
|
273
|
+
`structured-scan policy adapter ${adapter.name} pathPattern does not compile`,
|
|
274
|
+
{ adapter: adapter.name, cause: cause && cause.message ? cause.message : String(cause) },
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
adapters.push({ name: adapter.name, pattern });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
schemaVersion: policy.schemaVersion,
|
|
282
|
+
kind: policy.kind,
|
|
283
|
+
allowedNetworks: cidrs,
|
|
284
|
+
approvedRegistries: [...policy.approvedRegistries],
|
|
285
|
+
approvedCoordinates: new Set(policy.approvedCoordinates),
|
|
286
|
+
formatAdapters: adapters,
|
|
287
|
+
symlinkPolicy: policy.symlinkPolicy.mode,
|
|
288
|
+
binaryPolicy: { mode: policy.binaryPolicy.mode, approvedPaths: new Set(policy.binaryPolicy.approvedPaths) },
|
|
289
|
+
hostKeyPattern,
|
|
290
|
+
approvedRegistryHosts: registryHosts(policy.approvedRegistries),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ---------------------------------------------------------------- scanning
|
|
295
|
+
|
|
296
|
+
function scanViolation(relPath, rule, evidence) {
|
|
297
|
+
return mechanismError(HARNESS_ERROR_KINDS.STRUCTURED_SCAN_VIOLATION, "structured scan matched a violation", {
|
|
298
|
+
path: relPath,
|
|
299
|
+
rule,
|
|
300
|
+
evidence,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function checkHost(ctx, relPath, host, context, text, index) {
|
|
305
|
+
if (typeof host !== "string" || host.length === 0) return;
|
|
306
|
+
let bare = host;
|
|
307
|
+
const atIndex = bare.lastIndexOf("@");
|
|
308
|
+
if (atIndex !== -1) bare = bare.slice(atIndex + 1);
|
|
309
|
+
if (bare.startsWith("[")) {
|
|
310
|
+
const verdict = classifyIpToken(ctx.allowedNetworks, bare);
|
|
311
|
+
if (!verdict.allowed) {
|
|
312
|
+
throw scanViolation(relPath, STRUCTURED_SCAN_RULES.NON_PUBLIC_ADDRESS, `${host}(${verdict.reason})`);
|
|
313
|
+
}
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const portIndex = bare.indexOf(":");
|
|
317
|
+
if (portIndex !== -1) bare = bare.slice(0, portIndex);
|
|
318
|
+
bare = bare.toLowerCase().replace(/\.$/, "");
|
|
319
|
+
if (bare.length === 0) return;
|
|
320
|
+
if (isIpShapedToken(bare)) {
|
|
321
|
+
const verdict = classifyIpToken(ctx.allowedNetworks, bare);
|
|
322
|
+
if (!verdict.allowed) {
|
|
323
|
+
throw scanViolation(relPath, STRUCTURED_SCAN_RULES.NON_PUBLIC_ADDRESS, `${host}(${verdict.reason})`);
|
|
324
|
+
}
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (!bare.includes(".")) {
|
|
328
|
+
if (bare === "localhost") return;
|
|
329
|
+
if (context === "host-key" && ctx.hostKeyPattern?.test(bare)) return;
|
|
330
|
+
throw scanViolation(relPath, STRUCTURED_SCAN_RULES.NON_RESERVED_HOSTNAME, host);
|
|
331
|
+
}
|
|
332
|
+
if (ctx.approvedRegistryHosts.has(bare)) return;
|
|
333
|
+
throw scanViolation(relPath, STRUCTURED_SCAN_RULES.NON_RESERVED_HOSTNAME, host);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Full structure rules over one text buffer. Throws on first hit. */
|
|
337
|
+
function scanStructure(ctx, relPath, bytes) {
|
|
338
|
+
const text = bytes.toString("utf8");
|
|
339
|
+
|
|
340
|
+
for (const match of text.matchAll(URL_PATTERN)) checkHost(ctx, relPath, match[1], "url", text, match.index);
|
|
341
|
+
for (const match of text.matchAll(EMAIL_PATTERN)) checkHost(ctx, relPath, match[1], "email", text, match.index);
|
|
342
|
+
for (const match of text.matchAll(HOST_KEY_STRING_PATTERN)) checkHost(ctx, relPath, match[1], "host-key", text, match.index);
|
|
343
|
+
for (const arrayMatch of text.matchAll(HOST_KEY_ARRAY_PATTERN)) {
|
|
344
|
+
for (const tokenMatch of arrayMatch[1].matchAll(QUOTED_TOKEN_PATTERN)) {
|
|
345
|
+
checkHost(ctx, relPath, tokenMatch[1], "host-key", text, arrayMatch.index);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
for (const match of text.matchAll(NPM_SCOPE_PATTERN)) {
|
|
350
|
+
const coordinate = `@${match[1]}/${match[2]}`;
|
|
351
|
+
if (!ctx.approvedCoordinates.has(coordinate)) {
|
|
352
|
+
throw scanViolation(relPath, STRUCTURED_SCAN_RULES.PRIVATE_NPM_SCOPE, match[0]);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
for (const { token, index } of extractIpCandidates(text)) {
|
|
357
|
+
const verdict = classifyIpToken(ctx.allowedNetworks, token);
|
|
358
|
+
if (!verdict.allowed) {
|
|
359
|
+
throw scanViolation(relPath, STRUCTURED_SCAN_RULES.NON_PUBLIC_ADDRESS, `${token}(${verdict.reason})`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Binary judgement (fail-closed): non-text control bytes in the first 8KB. */
|
|
365
|
+
function looksBinary(bytes) {
|
|
366
|
+
const limit = Math.min(bytes.length, 8192);
|
|
367
|
+
for (let i = 0; i < limit; i += 1) {
|
|
368
|
+
const b = bytes[i];
|
|
369
|
+
if (b === 9 || b === 10 || b === 11 || b === 12 || b === 13) continue;
|
|
370
|
+
if (b < 32 || b === 127) return true;
|
|
371
|
+
}
|
|
372
|
+
return false;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ----------------------------------------------------------- format adapters
|
|
376
|
+
|
|
377
|
+
/** yaml AST comment regions; an unparseable document yields none (the
|
|
378
|
+
* semantic parse path reports format-adapter-parse-failed). */
|
|
379
|
+
export function collectLockfileCommentRegions(bytes) {
|
|
380
|
+
const text = Buffer.isBuffer(bytes) ? bytes.toString("utf8") : String(bytes);
|
|
381
|
+
let doc;
|
|
382
|
+
try {
|
|
383
|
+
doc = parseDocument(text);
|
|
384
|
+
} catch {
|
|
385
|
+
return [];
|
|
386
|
+
}
|
|
387
|
+
const regions = [];
|
|
388
|
+
if (doc.commentBefore) regions.push(doc.commentBefore);
|
|
389
|
+
if (doc.comment) regions.push(doc.comment);
|
|
390
|
+
visit(doc, {
|
|
391
|
+
Node(_key, node) {
|
|
392
|
+
if (node.commentBefore) regions.push(node.commentBefore);
|
|
393
|
+
if (node.comment) regions.push(node.comment);
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
return regions;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function lockfileDependencyName(depPath) {
|
|
400
|
+
let dep = String(depPath);
|
|
401
|
+
if (dep.startsWith("/")) dep = dep.slice(1);
|
|
402
|
+
if (dep.startsWith("@")) {
|
|
403
|
+
const secondAt = dep.indexOf("@", 1);
|
|
404
|
+
return secondAt === -1 ? dep : dep.slice(0, secondAt);
|
|
405
|
+
}
|
|
406
|
+
const firstAt = dep.indexOf("@");
|
|
407
|
+
return firstAt === -1 ? dep : dep.slice(0, firstAt);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function isLocalLockfileRef(depPathOrVersion) {
|
|
411
|
+
return (
|
|
412
|
+
typeof depPathOrVersion === "string" &&
|
|
413
|
+
LOCAL_LOCKFILE_REF_PREFIXES.some((prefix) => depPathOrVersion.startsWith(prefix))
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* "pnpm-lockfile" adapter: comment regions are scanned first (independent of
|
|
419
|
+
* the semantic parse), then @pnpm/lockfile.fs parses the document (parse
|
|
420
|
+
* failure fails closed); every non-local importer dependency and every
|
|
421
|
+
* packages/snapshots coordinate must be in approvedCoordinates (scoped =
|
|
422
|
+
* private-npm-scope, unscoped = unapproved-dependency-coordinate; orphan
|
|
423
|
+
* entries are judged too); URL references must start with an approved
|
|
424
|
+
* registry; keys and string leaf values continue through the full structure
|
|
425
|
+
* rules. The adapter writes one temporary copy under the OS temp directory
|
|
426
|
+
* for the parser and removes it afterwards.
|
|
427
|
+
*/
|
|
428
|
+
async function scanLockfileContent(ctx, relPath, bytes) {
|
|
429
|
+
const commentRegions = collectLockfileCommentRegions(bytes);
|
|
430
|
+
commentRegions.forEach((region, index) => {
|
|
431
|
+
scanStructure(ctx, `${relPath}#yaml-comment-${index + 1}`, Buffer.from(region, "utf8"));
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
let lockfile = null;
|
|
435
|
+
let parseError = null;
|
|
436
|
+
const tmpDir = mkdtempSync(path.join(os.tmpdir(), "sf-structured-lockfile-"));
|
|
437
|
+
try {
|
|
438
|
+
writeFileSync(path.join(tmpDir, "pnpm-lock.yaml"), bytes);
|
|
439
|
+
lockfile = await readWantedLockfile(tmpDir, { ignoreIncompatible: false });
|
|
440
|
+
} catch (cause) {
|
|
441
|
+
parseError = cause;
|
|
442
|
+
} finally {
|
|
443
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
444
|
+
}
|
|
445
|
+
// The semantic parse must yield a lockfile document (a versioned map); a
|
|
446
|
+
// scalar or otherwise non-semantic parse result is a parse failure.
|
|
447
|
+
if (
|
|
448
|
+
parseError ||
|
|
449
|
+
lockfile === null ||
|
|
450
|
+
typeof lockfile !== "object" ||
|
|
451
|
+
typeof lockfile.lockfileVersion !== "string"
|
|
452
|
+
) {
|
|
453
|
+
throw scanViolation(
|
|
454
|
+
relPath,
|
|
455
|
+
STRUCTURED_SCAN_RULES.FORMAT_ADAPTER_PARSE_FAILED,
|
|
456
|
+
`pnpm-lock.yaml could not be parsed by @pnpm/lockfile.fs, fail-closed(${String(parseError?.message ?? "no semantic lockfile document").slice(0, 200)})`,
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
for (const [importerId, importer] of Object.entries(lockfile.importers ?? {})) {
|
|
461
|
+
for (const section of ["dependencies", "devDependencies", "optionalDependencies"]) {
|
|
462
|
+
for (const [name, entry] of Object.entries(importer?.[section] ?? {})) {
|
|
463
|
+
if (isLocalLockfileRef(entry?.version)) continue;
|
|
464
|
+
if (!ctx.approvedCoordinates.has(name)) {
|
|
465
|
+
throw scanViolation(
|
|
466
|
+
relPath,
|
|
467
|
+
name.startsWith("@") ? STRUCTURED_SCAN_RULES.PRIVATE_NPM_SCOPE : STRUCTURED_SCAN_RULES.UNAPPROVED_DEPENDENCY_COORDINATE,
|
|
468
|
+
`importer ${importerId} dependency ${name}(version ${entry?.version ?? "?"})not in approvedCoordinates`,
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
for (const [mapName, map] of [["packages", lockfile.packages ?? {}], ["snapshots", lockfile.snapshots ?? {}]]) {
|
|
476
|
+
for (const [depPath, entry] of Object.entries(map)) {
|
|
477
|
+
const baseDepPath = depPath.split("(")[0];
|
|
478
|
+
if (isLocalLockfileRef(baseDepPath)) continue;
|
|
479
|
+
const name = lockfileDependencyName(baseDepPath);
|
|
480
|
+
if (!ctx.approvedCoordinates.has(name)) {
|
|
481
|
+
throw scanViolation(
|
|
482
|
+
relPath,
|
|
483
|
+
name.startsWith("@") ? STRUCTURED_SCAN_RULES.PRIVATE_NPM_SCOPE : STRUCTURED_SCAN_RULES.UNAPPROVED_DEPENDENCY_COORDINATE,
|
|
484
|
+
`${mapName} coordinate ${depPath} not in approvedCoordinates(orphan entries are not exempt)`,
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const walk = (node, segments) => {
|
|
491
|
+
if (Array.isArray(node)) {
|
|
492
|
+
node.forEach((item, index) => walk(item, [...segments, String(index)]));
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
if (node && typeof node === "object") {
|
|
496
|
+
for (const [key, value] of Object.entries(node)) {
|
|
497
|
+
if (typeof key === "string" && key.length > 0) {
|
|
498
|
+
scanStructure(ctx, `${relPath}#/${[...segments, key].join("/")}(key)`, Buffer.from(key, "utf8"));
|
|
499
|
+
}
|
|
500
|
+
walk(value, [...segments, key]);
|
|
501
|
+
}
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
if (typeof node === "string") {
|
|
505
|
+
if (LOCKFILE_URL_REFERENCE_PATTERN.test(node) && !ctx.approvedRegistries.some((registry) => node.startsWith(registry))) {
|
|
506
|
+
throw scanViolation(relPath, STRUCTURED_SCAN_RULES.NON_APPROVED_REGISTRY, `${segments.join("/") || "<root>"} references an unapproved registry: ${node.slice(0, 120)}`);
|
|
507
|
+
}
|
|
508
|
+
scanStructure(ctx, `${relPath}#/${segments.join("/")}`, Buffer.from(node, "utf8"));
|
|
509
|
+
} else if (node !== null && node !== undefined) {
|
|
510
|
+
scanStructure(ctx, `${relPath}#/${segments.join("/")}`, Buffer.from(String(node), "utf8"));
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
walk(lockfile, []);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* "tree-json" adapter: JSON.parse; a parse failure is
|
|
518
|
+
* format-adapter-parse-failed (fail-closed). There is no position-level
|
|
519
|
+
* whole-key exemption: every key and every value runs the full structure
|
|
520
|
+
* rules first.
|
|
521
|
+
*/
|
|
522
|
+
function scanTreeJsonContent(ctx, relPath, bytes) {
|
|
523
|
+
let parsed;
|
|
524
|
+
try {
|
|
525
|
+
parsed = JSON.parse(bytes.toString("utf8"));
|
|
526
|
+
} catch (cause) {
|
|
527
|
+
throw scanViolation(
|
|
528
|
+
relPath,
|
|
529
|
+
STRUCTURED_SCAN_RULES.FORMAT_ADAPTER_PARSE_FAILED,
|
|
530
|
+
`tree.json could not be JSON.parse'd, fail-closed(${String(cause?.message ?? cause).slice(0, 200)})`,
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const walk = (node, segments) => {
|
|
535
|
+
if (Array.isArray(node)) {
|
|
536
|
+
node.forEach((item, index) => walk(item, [...segments, String(index)]));
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
if (node && typeof node === "object") {
|
|
540
|
+
for (const [key, value] of Object.entries(node)) {
|
|
541
|
+
if (typeof key === "string" && key.length > 0) {
|
|
542
|
+
scanStructure(ctx, `${relPath}#/${[...segments, key].join("/")}(key)`, Buffer.from(key, "utf8"));
|
|
543
|
+
}
|
|
544
|
+
walk(value, [...segments, key]);
|
|
545
|
+
}
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
if (node !== null && node !== undefined) {
|
|
549
|
+
scanStructure(ctx, `${relPath}#/${segments.join("/")}`, Buffer.from(String(node), "utf8"));
|
|
550
|
+
}
|
|
551
|
+
};
|
|
552
|
+
walk(parsed, []);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function findAdapter(ctx, relPath) {
|
|
556
|
+
const normalized = relPath.split(path.sep).join("/");
|
|
557
|
+
for (const adapter of ctx.formatAdapters) {
|
|
558
|
+
if (adapter.pattern.test(normalized)) return adapter;
|
|
559
|
+
}
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const BUILTIN_ADAPTERS = Object.freeze({
|
|
564
|
+
"pnpm-lockfile": scanLockfileContent,
|
|
565
|
+
"tree-json": scanTreeJsonContent,
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
// ------------------------------------------------------------------- entry
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Structured surface scan. Returns a frozen { scanned, bytes, policy } result
|
|
572
|
+
* mirroring scanSurface; a violation throws SFC2004 with details.kind
|
|
573
|
+
* structured-scan-violation and details.rule from the closed vocabulary, and
|
|
574
|
+
* an invalid policy throws structured-scan-invalid.
|
|
575
|
+
*/
|
|
576
|
+
export async function scanSurfaceStructured({ root, relPaths, policy, encoding = "utf8" } = {}) {
|
|
577
|
+
if (typeof root !== "string" || root.length === 0) {
|
|
578
|
+
throw new TypeError("scanSurfaceStructured: root must be a non-empty path");
|
|
579
|
+
}
|
|
580
|
+
if (!Array.isArray(relPaths)) {
|
|
581
|
+
throw new TypeError("scanSurfaceStructured: relPaths must be an array of root-relative path strings");
|
|
582
|
+
}
|
|
583
|
+
if (!policy || typeof policy !== "object" || Array.isArray(policy)) {
|
|
584
|
+
throw new TypeError("scanSurfaceStructured: policy must be a structured-scan-policy contract document");
|
|
585
|
+
}
|
|
586
|
+
const ctx = loadPolicy(policy);
|
|
587
|
+
|
|
588
|
+
const scanned = [];
|
|
589
|
+
let bytes = 0;
|
|
590
|
+
for (const relPath of relPaths) {
|
|
591
|
+
if (typeof relPath !== "string" || relPath.length === 0) {
|
|
592
|
+
throw new TypeError("scanSurfaceStructured: every relPath entry must be a non-empty string");
|
|
593
|
+
}
|
|
594
|
+
const normalized = relPath.split(path.sep).join("/");
|
|
595
|
+
const classification = classifyPathInput(relPath);
|
|
596
|
+
if (!classification.ok) {
|
|
597
|
+
throw mechanismError(
|
|
598
|
+
classification.kind,
|
|
599
|
+
`path rejected before resolution (kind: ${classification.kind})`,
|
|
600
|
+
{ input: relPath },
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
// Symlink entries are judged before any resolution: readlink-no-follow
|
|
604
|
+
// records the target text and the target is never read. This applies to
|
|
605
|
+
// in-tree links and links that would escape alike.
|
|
606
|
+
const fullPath = path.resolve(root, relPath);
|
|
607
|
+
let stat;
|
|
608
|
+
try {
|
|
609
|
+
stat = lstatSync(fullPath);
|
|
610
|
+
} catch (cause) {
|
|
611
|
+
throw mechanismError(
|
|
612
|
+
HARNESS_ERROR_KINDS.STRUCTURED_SCAN_INVALID,
|
|
613
|
+
"scanSurfaceStructured: declared resource cannot be inspected",
|
|
614
|
+
{ path: normalized, cause: cause && cause.message ? cause.message : String(cause) },
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
if (stat.isSymbolicLink()) {
|
|
618
|
+
let target = "<unreadable>";
|
|
619
|
+
try {
|
|
620
|
+
target = readlinkSync(fullPath);
|
|
621
|
+
} catch {
|
|
622
|
+
// readlink failure is itself evidence: never follow, only record.
|
|
623
|
+
}
|
|
624
|
+
throw scanViolation(normalized, STRUCTURED_SCAN_RULES.SYMLINK_FORBIDDEN, `target: ${target}`);
|
|
625
|
+
}
|
|
626
|
+
await resolveContained(root, relPath);
|
|
627
|
+
const readResult = await readFileContained(root, relPath);
|
|
628
|
+
const raw = Buffer.isBuffer(readResult) ? readResult : Buffer.from(readResult);
|
|
629
|
+
bytes += raw.length;
|
|
630
|
+
scanned.push(normalized);
|
|
631
|
+
|
|
632
|
+
if (looksBinary(raw)) {
|
|
633
|
+
if (!ctx.binaryPolicy.approvedPaths.has(normalized)) {
|
|
634
|
+
throw scanViolation(normalized, STRUCTURED_SCAN_RULES.BINARY_UNCLASSIFIED, "<non-text control byte; fail-closed>");
|
|
635
|
+
}
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const adapter = findAdapter(ctx, normalized);
|
|
640
|
+
if (adapter !== null) {
|
|
641
|
+
const implementation = BUILTIN_ADAPTERS[adapter.name];
|
|
642
|
+
if (!implementation) {
|
|
643
|
+
throw scanViolation(normalized, STRUCTURED_SCAN_RULES.FORMAT_ADAPTER_UNKNOWN, `unimplemented adapter ${adapter.name}, fail-closed`);
|
|
644
|
+
}
|
|
645
|
+
await implementation(ctx, normalized, raw);
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
scanStructure(ctx, normalized, raw);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
return Object.freeze({ scanned, bytes, policy: structuredClone(policy) });
|
|
652
|
+
}
|