@codyswann/lisa 2.317.7 → 2.319.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/cdk/package-lisa/package.lisa.json +2 -2
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +12 -7
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/expo/package-lisa/package.lisa.json +5 -5
- package/nestjs/package-lisa/package.lisa.json +1 -1
- package/package.json +1 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/doctor-secrets.mjs +34 -6
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/surfaces.mjs +12 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/validate-config.mjs +65 -5
- package/plugins/lisa/skills/lisa-secrets-access/scripts/doctor-secrets.mjs +34 -6
- package/plugins/lisa/skills/lisa-secrets-access/scripts/surfaces.mjs +12 -0
- package/plugins/lisa/skills/lisa-secrets-access/scripts/validate-config.mjs +65 -5
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/doctor-secrets.mjs +34 -6
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/surfaces.mjs +12 -0
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/validate-config.mjs +65 -5
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/doctor-secrets.mjs +34 -6
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/surfaces.mjs +12 -0
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/validate-config.mjs +65 -5
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/doctor-secrets.mjs +34 -6
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/surfaces.mjs +12 -0
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/validate-config.mjs +65 -5
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/skills/lisa-secrets-access/scripts/doctor-secrets.mjs +34 -6
- package/plugins/src/base/skills/lisa-secrets-access/scripts/surfaces.mjs +12 -0
- package/plugins/src/base/skills/lisa-secrets-access/scripts/validate-config.mjs +65 -5
- package/scripts/check-security-floors.mjs +285 -0
- package/typescript/package-lisa/package.lisa.json +6 -6
|
@@ -47,6 +47,16 @@ function fingerprint(value) {
|
|
|
47
47
|
return createHash("sha256").update(value).digest("hex").slice(0, 12);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* The value a proxied credential reads as instead of a usable token.
|
|
52
|
+
*
|
|
53
|
+
* Some surfaces keep a credential outside the sandbox entirely and substitute
|
|
54
|
+
* the real value at egress. The variable is then present and non-empty, which
|
|
55
|
+
* is exactly what makes it dangerous: a presence check passes, and only a
|
|
56
|
+
* consumer that reads the variable *itself* discovers it holds a placeholder.
|
|
57
|
+
*/
|
|
58
|
+
const PROXY_PLACEHOLDER = "proxy-injected";
|
|
59
|
+
|
|
50
60
|
/**
|
|
51
61
|
* Assert every declared name actually resolves.
|
|
52
62
|
* @param {object} cfg Resolved configuration.
|
|
@@ -56,18 +66,36 @@ function fingerprint(value) {
|
|
|
56
66
|
*/
|
|
57
67
|
export function checkRequired(cfg, provider, file, report) {
|
|
58
68
|
for (const name of cfg.require ?? []) {
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (resolves) report("ok", name, "resolves");
|
|
64
|
-
else
|
|
69
|
+
const fromEnv = (process.env[name] ?? "").trim();
|
|
70
|
+
const resolves = fromEnv || file.get(name) || provider.get(name)?.value;
|
|
71
|
+
|
|
72
|
+
if (!resolves) {
|
|
65
73
|
report(
|
|
66
74
|
"error",
|
|
67
75
|
name,
|
|
68
76
|
"declared in secrets.require but resolves nowhere — a startup error, " +
|
|
69
77
|
"not a late surprise"
|
|
70
78
|
);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Reported separately from "resolves" because the two are not the same
|
|
83
|
+
// claim. A tool that authenticates through the proxy works; a script that
|
|
84
|
+
// reads this variable and puts it in an Authorization header sends the
|
|
85
|
+
// literal placeholder and gets a confusing auth failure far from here.
|
|
86
|
+
if (fromEnv === PROXY_PLACEHOLDER) {
|
|
87
|
+
report(
|
|
88
|
+
"warn",
|
|
89
|
+
name,
|
|
90
|
+
`reads as the literal string "${PROXY_PLACEHOLDER}" — the value is ` +
|
|
91
|
+
`substituted at egress and never enters this environment. Tools that ` +
|
|
92
|
+
`authenticate through the proxy work; anything reading this variable ` +
|
|
93
|
+
`directly gets the placeholder, not a credential`
|
|
94
|
+
);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
report("ok", name, "resolves");
|
|
71
99
|
}
|
|
72
100
|
}
|
|
73
101
|
|
|
@@ -28,11 +28,19 @@ import { join } from "node:path";
|
|
|
28
28
|
* drift and leak. It is *required* on surfaces whose bootstrap runs before the
|
|
29
29
|
* consuming process exists and which therefore have no other channel; a remote
|
|
30
30
|
* agent container prepares itself during setup, long before any task starts.
|
|
31
|
+
*
|
|
32
|
+
* The two remote surfaces share the capability but not the timing, and a reader
|
|
33
|
+
* adding a third should not assume otherwise. `codex-cloud` re-runs its setup
|
|
34
|
+
* script when a container resumes, so materializing there picks up a rotated
|
|
35
|
+
* value. `claude-web` *skips* its setup script whenever a filesystem cache
|
|
36
|
+
* exists, so materializing there would strand a rotated value until the cache
|
|
37
|
+
* expired — its materialize step runs from a session-start hook instead.
|
|
31
38
|
*/
|
|
32
39
|
export const SURFACES = {
|
|
33
40
|
local: { materialized: false, mayWriteValues: false },
|
|
34
41
|
"github-actions": { materialized: false, mayWriteValues: false },
|
|
35
42
|
"codex-cloud": { materialized: true, mayWriteValues: true },
|
|
43
|
+
"claude-web": { materialized: true, mayWriteValues: true },
|
|
36
44
|
};
|
|
37
45
|
|
|
38
46
|
/** Config defaults when `.lisa.config.json` carries no `secrets` block. */
|
|
@@ -68,6 +76,10 @@ export function detectSurface(configured = null, env = process.env) {
|
|
|
68
76
|
return explicit;
|
|
69
77
|
}
|
|
70
78
|
if ((env.GITHUB_ACTIONS ?? "") === "true") return "github-actions";
|
|
79
|
+
// Compared to the exact string rather than tested for presence: the variable
|
|
80
|
+
// is documented as carrying "true" in a cloud session and never being true
|
|
81
|
+
// locally, so a presence test would misread a shell that exports it empty.
|
|
82
|
+
if ((env.CLAUDE_CODE_REMOTE ?? "") === "true") return "claude-web";
|
|
71
83
|
if ((env.CODEX_SANDBOX ?? env.CODEX_HOME ?? "") !== "") return "codex-cloud";
|
|
72
84
|
return "local";
|
|
73
85
|
}
|
|
@@ -19,14 +19,59 @@
|
|
|
19
19
|
import { existsSync, readFileSync } from "node:fs";
|
|
20
20
|
import { join } from "node:path";
|
|
21
21
|
|
|
22
|
+
import { SURFACES as SURFACE_CAPABILITIES } from "./surfaces.mjs";
|
|
23
|
+
|
|
22
24
|
/** Providers with a read implementation today. */
|
|
23
25
|
const IMPLEMENTED_PROVIDERS = new Set(["bitwarden", "doppler", "env"]);
|
|
24
26
|
|
|
25
27
|
/** Providers named in the dispatch table but not yet implemented. */
|
|
26
28
|
const DECLARED_PROVIDERS = new Set(["1password", "aws", "vault"]);
|
|
27
29
|
|
|
28
|
-
/**
|
|
29
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Surfaces the resolver knows.
|
|
32
|
+
*
|
|
33
|
+
* Derived from the resolver's own table rather than restated here. The two
|
|
34
|
+
* lists previously drifted apart by construction: adding a surface meant
|
|
35
|
+
* remembering to edit a second file, and forgetting produced a config that
|
|
36
|
+
* resolved correctly at runtime while `doctor` called it unknown.
|
|
37
|
+
*/
|
|
38
|
+
const SURFACES = new Set(Object.keys(SURFACE_CAPABILITIES));
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* What a provisioned surface must record before anything dispatches to it.
|
|
42
|
+
*
|
|
43
|
+
* Deliberately not uniform, because these surfaces do not bind the same way. A
|
|
44
|
+
* Codex Cloud environment is bound to one repository, so naming the repository
|
|
45
|
+
* is part of proving the environment is the right one. A Claude cloud
|
|
46
|
+
* environment has no repository at all — it is account-scoped configuration
|
|
47
|
+
* (network policy, variables, setup script) and the repository arrives per
|
|
48
|
+
* session — so its durable handle is the routine that dispatch fires.
|
|
49
|
+
*
|
|
50
|
+
* Requiring `repository` of every surface, as this file used to, would demand
|
|
51
|
+
* a field that cannot be true of `claude-web` in any meaningful sense.
|
|
52
|
+
*
|
|
53
|
+
* `repository` stays the default so every existing surface keeps its current
|
|
54
|
+
* contract. This file checks structure only — whether a declaration *could* be
|
|
55
|
+
* correct — so it deliberately does not restate the fuller preconditions that
|
|
56
|
+
* `lisa-remote-dispatch` enforces at the moment it actually dispatches.
|
|
57
|
+
*/
|
|
58
|
+
const SURFACE_BINDINGS = {
|
|
59
|
+
"claude-web": ["routineId", "fireUrl"],
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The fields a surface must record, with the default applied.
|
|
64
|
+
*
|
|
65
|
+
* Read through one helper rather than at each call site, because the two
|
|
66
|
+
* callers answer different questions — "could this declaration be correct" and
|
|
67
|
+
* "may an automation dispatch to it" — and a fallback that drifted between them
|
|
68
|
+
* would let those answers disagree about the very same config.
|
|
69
|
+
* @param {string} surface Surface name.
|
|
70
|
+
* @returns {string[]} Field names that must be present.
|
|
71
|
+
*/
|
|
72
|
+
function bindingsFor(surface) {
|
|
73
|
+
return SURFACE_BINDINGS[surface] ?? ["repository"];
|
|
74
|
+
}
|
|
30
75
|
|
|
31
76
|
/** Install methods the toolchain runner supports. */
|
|
32
77
|
const INSTALL_METHODS = new Set(["release-zip", "npm-global"]);
|
|
@@ -139,15 +184,30 @@ export function validateRemoteEnv(remoteEnv) {
|
|
|
139
184
|
for (const [surface, block] of Object.entries(remoteEnv.surfaces ?? {})) {
|
|
140
185
|
if (!SURFACES.has(surface)) {
|
|
141
186
|
problems.push(`remoteEnv.surfaces has unknown surface "${surface}"`);
|
|
187
|
+
continue;
|
|
142
188
|
}
|
|
143
|
-
|
|
144
|
-
|
|
189
|
+
for (const field of bindingsFor(surface)) {
|
|
190
|
+
if (!block[field]) {
|
|
191
|
+
problems.push(`remoteEnv.surfaces["${surface}"] has no ${field}`);
|
|
192
|
+
}
|
|
145
193
|
}
|
|
146
194
|
}
|
|
147
195
|
|
|
148
196
|
return problems;
|
|
149
197
|
}
|
|
150
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Report whether a surface has been provisioned far enough to dispatch to.
|
|
201
|
+
* @param {object|undefined} remoteEnv The remote-environment block.
|
|
202
|
+
* @param {string} surface Surface name.
|
|
203
|
+
* @returns {boolean} Whether every binding field is recorded.
|
|
204
|
+
*/
|
|
205
|
+
export function isProvisioned(remoteEnv, surface) {
|
|
206
|
+
const block = remoteEnv?.surfaces?.[surface];
|
|
207
|
+
if (!block) return false;
|
|
208
|
+
return bindingsFor(surface).every(field => Boolean(block[field]));
|
|
209
|
+
}
|
|
210
|
+
|
|
151
211
|
/**
|
|
152
212
|
* Validate the `automations` block against declared surfaces.
|
|
153
213
|
* @param {object|undefined} automations The block, if present.
|
|
@@ -173,7 +233,7 @@ export function validateAutomations(automations, remoteEnv) {
|
|
|
173
233
|
);
|
|
174
234
|
continue;
|
|
175
235
|
}
|
|
176
|
-
if (!remoteEnv
|
|
236
|
+
if (!isProvisioned(remoteEnv, loop.executionEnv)) {
|
|
177
237
|
problems.push(
|
|
178
238
|
`automations["${name}"] dispatches to "${loop.executionEnv}", which is ` +
|
|
179
239
|
`not provisioned. Run /lisa:setup:remote-env ${loop.executionEnv} first.`
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Verify no force-pinned version floor permits a known-vulnerable release.
|
|
4
|
+
*
|
|
5
|
+
* A floor in a `force` section does not merely fail to protect when it goes
|
|
6
|
+
* stale — it OVERWRITES whatever the downstream project had. A project that
|
|
7
|
+
* pinned correctly gets moved back onto the vulnerable range by `lisa apply`,
|
|
8
|
+
* silently, and the only signal is a suspicious line in a diff nobody is
|
|
9
|
+
* looking for. That is how `brace-expansion >=5.0.7` survived: it was written
|
|
10
|
+
* to match GHSA-3jxr-9vmj-r5cp, whose first_patched_version genuinely is
|
|
11
|
+
* 5.0.7, and GHSA-mh99-v99m-4gvg later moved the floor to 5.0.8.
|
|
12
|
+
*
|
|
13
|
+
* The floors are therefore checked against the advisory database rather than
|
|
14
|
+
* against what npm happens to resolve. Resolution drifts as new advisories
|
|
15
|
+
* land; `first_patched_version` does not. Deriving floors from resolution is
|
|
16
|
+
* the specific mistake this exists to catch — it produced the same defect
|
|
17
|
+
* three times (fast-uri, js-yaml, and the three fixed in #2168).
|
|
18
|
+
*
|
|
19
|
+
* Usage:
|
|
20
|
+
* check-security-floors.mjs [--strict] [--json]
|
|
21
|
+
*
|
|
22
|
+
* Exits non-zero only with --strict, so the job can report before it gates.
|
|
23
|
+
* @module scripts/check-security-floors
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { readFileSync } from "node:fs";
|
|
27
|
+
import { globSync } from "node:fs";
|
|
28
|
+
|
|
29
|
+
/** Sections whose entries are version constraints worth checking. */
|
|
30
|
+
const CONSTRAINT_SECTIONS = [
|
|
31
|
+
"overrides",
|
|
32
|
+
"resolutions",
|
|
33
|
+
"dependencies",
|
|
34
|
+
"devDependencies",
|
|
35
|
+
"peerDependencies",
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
/** Governance groups in a package.lisa.json. */
|
|
39
|
+
const GOVERNANCE_GROUPS = ["force", "defaults", "merge"];
|
|
40
|
+
|
|
41
|
+
/** Severities that gate. Medium and low advisories are ignored by this check. */
|
|
42
|
+
const GATING_SEVERITIES = new Set(["high", "critical"]);
|
|
43
|
+
|
|
44
|
+
const ADVISORY_ENDPOINT = "https://api.github.com/advisories";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Every version constraint declared across the template manifests.
|
|
48
|
+
* @returns {Map<string, Array<{file: string, path: string, spec: string}>>} By package name.
|
|
49
|
+
*/
|
|
50
|
+
function collectFloors() {
|
|
51
|
+
const found = new Map();
|
|
52
|
+
const files = globSync("*/package-lisa/package.lisa.json").filter(
|
|
53
|
+
file => !file.includes("node_modules") && !file.includes(".worktrees")
|
|
54
|
+
);
|
|
55
|
+
for (const file of files) {
|
|
56
|
+
let manifest;
|
|
57
|
+
try {
|
|
58
|
+
manifest = JSON.parse(readFileSync(file, "utf8"));
|
|
59
|
+
} catch (error) {
|
|
60
|
+
throw new Error(`${file} is not valid JSON: ${error.message}`);
|
|
61
|
+
}
|
|
62
|
+
for (const group of GOVERNANCE_GROUPS) {
|
|
63
|
+
const block = manifest[group];
|
|
64
|
+
if (!block || typeof block !== "object") continue;
|
|
65
|
+
for (const section of CONSTRAINT_SECTIONS) {
|
|
66
|
+
const entries = block[section];
|
|
67
|
+
if (!entries || typeof entries !== "object") continue;
|
|
68
|
+
for (const [name, spec] of Object.entries(entries)) {
|
|
69
|
+
// `$name` self-references defer to the project's own pin and carry
|
|
70
|
+
// no floor of their own; a literal like "workspace:*" likewise.
|
|
71
|
+
if (typeof spec !== "string" || !/\d/.test(spec)) continue;
|
|
72
|
+
if (spec.startsWith("$")) continue;
|
|
73
|
+
if (!found.has(name)) found.set(name, []);
|
|
74
|
+
found.get(name).push({ file, path: `${group}.${section}`, spec });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return found;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Lowest version a spec permits, as a comparable tuple.
|
|
84
|
+
* @param {string} spec A range like ">=5.0.7", "^1.2.3", "~2.0.0".
|
|
85
|
+
* @returns {number[]|null} [major, minor, patch], or null if unparseable.
|
|
86
|
+
*/
|
|
87
|
+
export function lowestPermitted(spec) {
|
|
88
|
+
const match = /(\d+)\.(\d+)\.(\d+)/.exec(spec);
|
|
89
|
+
if (!match) return null;
|
|
90
|
+
return match.slice(1, 4).map(Number);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Compare two version tuples.
|
|
95
|
+
* @param {number[]} left First version.
|
|
96
|
+
* @param {number[]} right Second version.
|
|
97
|
+
* @returns {number} Negative, zero, or positive.
|
|
98
|
+
*/
|
|
99
|
+
function compare(left, right) {
|
|
100
|
+
for (let index = 0; index < 3; index += 1) {
|
|
101
|
+
if (left[index] !== right[index]) return left[index] - right[index];
|
|
102
|
+
}
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Whether a version falls inside an advisory's vulnerable range.
|
|
108
|
+
* @param {number[]} version Candidate version.
|
|
109
|
+
* @param {string} range e.g. ">= 4.0.0, < 5.0.8".
|
|
110
|
+
* @returns {boolean} True when the version is vulnerable.
|
|
111
|
+
*/
|
|
112
|
+
export function withinRange(version, range) {
|
|
113
|
+
if (!range) return false;
|
|
114
|
+
let inside = true;
|
|
115
|
+
for (const clause of range.split(",")) {
|
|
116
|
+
const match = /(>=|<=|<|>|=)\s*(\d+)\.(\d+)\.(\d+)/.exec(clause.trim());
|
|
117
|
+
if (!match) continue;
|
|
118
|
+
const bound = match.slice(2, 5).map(Number);
|
|
119
|
+
const delta = compare(version, bound);
|
|
120
|
+
switch (match[1]) {
|
|
121
|
+
case ">=":
|
|
122
|
+
inside &&= delta >= 0;
|
|
123
|
+
break;
|
|
124
|
+
case ">":
|
|
125
|
+
inside &&= delta > 0;
|
|
126
|
+
break;
|
|
127
|
+
case "<=":
|
|
128
|
+
inside &&= delta <= 0;
|
|
129
|
+
break;
|
|
130
|
+
case "<":
|
|
131
|
+
inside &&= delta < 0;
|
|
132
|
+
break;
|
|
133
|
+
default:
|
|
134
|
+
inside &&= delta === 0;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return inside;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Advisories affecting one package.
|
|
142
|
+
*
|
|
143
|
+
* Distinguishes "no advisories" from "could not ask", because conflating them
|
|
144
|
+
* is how a check like this goes quietly blind. The anonymous advisory limit is
|
|
145
|
+
* 60 requests/hour and these manifests declare roughly 200 packages, so an
|
|
146
|
+
* unauthenticated run answers for the first 60 and silently reports the other
|
|
147
|
+
* 140 as clean unless the difference is tracked.
|
|
148
|
+
* @param {string} name npm package name.
|
|
149
|
+
* @returns {Promise<{advisories: Array}|{error: string}>} Result or reason.
|
|
150
|
+
*/
|
|
151
|
+
async function advisoriesFor(name) {
|
|
152
|
+
const url = `${ADVISORY_ENDPOINT}?ecosystem=npm&affects=${encodeURIComponent(name)}&per_page=100`;
|
|
153
|
+
const headers = { accept: "application/vnd.github+json" };
|
|
154
|
+
// Authenticated calls get 5000/hour instead of 60. CI supplies the token via
|
|
155
|
+
// the standard GITHUB_TOKEN; locally, `gh auth token` covers it.
|
|
156
|
+
const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
|
|
157
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
158
|
+
|
|
159
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
160
|
+
let response;
|
|
161
|
+
try {
|
|
162
|
+
response = await fetch(url, { headers });
|
|
163
|
+
} catch (error) {
|
|
164
|
+
return { error: `network: ${error.message}` };
|
|
165
|
+
}
|
|
166
|
+
if (response.ok) return { advisories: await response.json() };
|
|
167
|
+
|
|
168
|
+
const remaining = response.headers.get("x-ratelimit-remaining");
|
|
169
|
+
const limited =
|
|
170
|
+
response.status === 429 || (response.status === 403 && remaining === "0");
|
|
171
|
+
if (!limited) return { error: `HTTP ${response.status}` };
|
|
172
|
+
|
|
173
|
+
// Honour Retry-After when given, otherwise back off. A rate-limited run
|
|
174
|
+
// cannot demonstrate anything, so it is worth waiting rather than
|
|
175
|
+
// reporting a clean sheet that was never checked.
|
|
176
|
+
const retryAfter = Number(response.headers.get("retry-after"));
|
|
177
|
+
const waitSeconds =
|
|
178
|
+
Number.isFinite(retryAfter) && retryAfter > 0
|
|
179
|
+
? Math.min(retryAfter, 30)
|
|
180
|
+
: 2 ** attempt;
|
|
181
|
+
await new Promise(resolve => setTimeout(resolve, waitSeconds * 1000));
|
|
182
|
+
}
|
|
183
|
+
return { error: "rate limited" };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Audit every collected floor.
|
|
188
|
+
* @returns {Promise<{problems: Array, unreachable: string[], checked: number}>} Findings.
|
|
189
|
+
*/
|
|
190
|
+
async function audit() {
|
|
191
|
+
const floors = collectFloors();
|
|
192
|
+
const problems = [];
|
|
193
|
+
const unreachable = [];
|
|
194
|
+
for (const [name, sites] of floors) {
|
|
195
|
+
const result = await advisoriesFor(name);
|
|
196
|
+
if (result.error) {
|
|
197
|
+
unreachable.push({ name, reason: result.error });
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const { advisories } = result;
|
|
201
|
+
for (const advisory of advisories) {
|
|
202
|
+
if (!GATING_SEVERITIES.has(advisory.severity)) continue;
|
|
203
|
+
for (const vulnerability of advisory.vulnerabilities ?? []) {
|
|
204
|
+
if (vulnerability.package?.name !== name) continue;
|
|
205
|
+
for (const site of sites) {
|
|
206
|
+
const lowest = lowestPermitted(site.spec);
|
|
207
|
+
if (!lowest) continue;
|
|
208
|
+
if (!withinRange(lowest, vulnerability.vulnerable_version_range)) {
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
problems.push({
|
|
212
|
+
package: name,
|
|
213
|
+
...site,
|
|
214
|
+
advisory: advisory.ghsa_id,
|
|
215
|
+
severity: advisory.severity,
|
|
216
|
+
vulnerableRange: vulnerability.vulnerable_version_range,
|
|
217
|
+
patched: vulnerability.first_patched_version,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return { problems, unreachable, checked: floors.size };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function main() {
|
|
227
|
+
const strict = process.argv.includes("--strict");
|
|
228
|
+
const asJson = process.argv.includes("--json");
|
|
229
|
+
const { problems, unreachable, checked } = await audit();
|
|
230
|
+
|
|
231
|
+
if (asJson) {
|
|
232
|
+
console.log(JSON.stringify({ problems, unreachable, checked }, null, 2));
|
|
233
|
+
} else if (problems.length === 0) {
|
|
234
|
+
console.log(
|
|
235
|
+
`## Security floors\n\nNo force-pinned floor permits a high or critical vulnerable release. ${checked} package(s) checked.`
|
|
236
|
+
);
|
|
237
|
+
} else {
|
|
238
|
+
console.log("## Security floors\n");
|
|
239
|
+
console.log(
|
|
240
|
+
"These pins permit a release the advisory database marks vulnerable. Because they sit in governance sections, a stale floor **overwrites** a downstream project that pinned correctly.\n"
|
|
241
|
+
);
|
|
242
|
+
console.log("| package | pinned | permits | advisory | patched | site |");
|
|
243
|
+
console.log("|---|---|---|---|---|---|");
|
|
244
|
+
for (const problem of problems) {
|
|
245
|
+
console.log(
|
|
246
|
+
`| \`${problem.package}\` | \`${problem.spec}\` | ${problem.vulnerableRange} | ${problem.advisory} (${problem.severity}) | **${problem.patched}** | ${problem.file} ${problem.path} |`
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
console.log(
|
|
250
|
+
"\nRaise each floor to the advisory's `first_patched_version`, then confirm the new value actually resolves on npm — a floor nothing satisfies is its own breakage."
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (unreachable.length > 0) {
|
|
255
|
+
const limited = unreachable.filter(entry =>
|
|
256
|
+
entry.reason.includes("rate limited")
|
|
257
|
+
);
|
|
258
|
+
console.log(
|
|
259
|
+
`\n> **Inconclusive for ${unreachable.length} of ${checked} package(s).** Their floors were NOT verified — this is not a clean result for them.`
|
|
260
|
+
);
|
|
261
|
+
if (limited.length > 0) {
|
|
262
|
+
console.log(
|
|
263
|
+
"> Cause is rate limiting. The anonymous advisory limit is 60 requests/hour; set `GITHUB_TOKEN` for 5000."
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
console.log(
|
|
267
|
+
`> Affected: ${unreachable.map(entry => entry.name).join(", ")}`
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Deliberately exit 0 without --strict. An advisory landing overnight would
|
|
272
|
+
// otherwise fail a PR that changed nothing, and a check that cries wolf gets
|
|
273
|
+
// disabled rather than fixed.
|
|
274
|
+
//
|
|
275
|
+
// Under --strict an inconclusive run also fails: it proves nothing, and
|
|
276
|
+
// treating "could not check" as "checked and clean" is exactly the silent
|
|
277
|
+
// degradation this script exists to prevent.
|
|
278
|
+
if (strict && (problems.length > 0 || unreachable.length > 0)) {
|
|
279
|
+
process.exitCode = 1;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (import.meta.main) {
|
|
284
|
+
await main();
|
|
285
|
+
}
|