@denial-web/clawguard 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/.clawguard.example.json +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +241 -0
  4. package/SECURITY.md +33 -0
  5. package/action.yml +72 -0
  6. package/docs/ARCHITECTURE.md +312 -0
  7. package/docs/ARCHITECTURE_ROADMAP.md +267 -0
  8. package/docs/CLAWHUB_METADATA.md +57 -0
  9. package/docs/DEMO_CAPTURE.md +25 -0
  10. package/docs/DEMO_SCRIPT.md +87 -0
  11. package/docs/DEPENDENCY_SCANNING.md +61 -0
  12. package/docs/GITHUB_ACTION.md +56 -0
  13. package/docs/GITHUB_REPO_SETUP.md +76 -0
  14. package/docs/HTML_REPORTS.md +27 -0
  15. package/docs/INTEGRATION_SPEC.md +253 -0
  16. package/docs/LAUNCH_CHECKLIST.md +64 -0
  17. package/docs/LAUNCH_PLAN.md +40 -0
  18. package/docs/LOCAL_PROJECT_ASSETS.md +250 -0
  19. package/docs/MCP_PLUGIN_SCANNING.md +53 -0
  20. package/docs/NEXT_SESSION.md +110 -0
  21. package/docs/NPM_PUBLISHING.md +66 -0
  22. package/docs/OPENCLAW_CLAWHUB_RESEARCH.md +128 -0
  23. package/docs/POLICY_MODEL.md +198 -0
  24. package/docs/PROJECT_REVIEW.md +108 -0
  25. package/docs/REAL_WORLD_VALIDATION.md +57 -0
  26. package/docs/RELEASE_NOTES_v0.1.0.md +52 -0
  27. package/docs/REPORT_SCHEMA.md +81 -0
  28. package/docs/RULES.md +92 -0
  29. package/docs/THREAT_MODEL.md +50 -0
  30. package/docs/WEB_DEMO.md +39 -0
  31. package/docs/WORKSPACE_SCANNING.md +41 -0
  32. package/examples/clawhub-origin-without-lock/skills/orphan-helper/.clawhub/origin.json +6 -0
  33. package/examples/clawhub-origin-without-lock/skills/orphan-helper/SKILL.md +11 -0
  34. package/examples/clawhub-workspace/.clawhub/lock.json +22 -0
  35. package/examples/clawhub-workspace/skills/drift-helper/.clawhub/origin.json +6 -0
  36. package/examples/clawhub-workspace/skills/drift-helper/SKILL.md +11 -0
  37. package/examples/clawhub-workspace/skills/missing-origin/SKILL.md +11 -0
  38. package/examples/clawhub-workspace/skills/weather-helper/.clawhub/origin.json +6 -0
  39. package/examples/clawhub-workspace/skills/weather-helper/SKILL.md +15 -0
  40. package/examples/declared-api-skill/SKILL.md +27 -0
  41. package/examples/dependency-python-skill/SKILL.md +16 -0
  42. package/examples/dependency-python-skill/pyproject.toml +5 -0
  43. package/examples/dependency-python-skill/requirements.txt +3 -0
  44. package/examples/dependency-risky-skill/SKILL.md +16 -0
  45. package/examples/dependency-risky-skill/package.json +12 -0
  46. package/examples/dependency-safe-skill/SKILL.md +16 -0
  47. package/examples/dependency-safe-skill/package-lock.json +19 -0
  48. package/examples/dependency-safe-skill/package.json +7 -0
  49. package/examples/metadata-mismatch-skill/SKILL.md +22 -0
  50. package/examples/openclaw-plugin-config/.openclaw/plugins.json +18 -0
  51. package/examples/openclaw-workspace/.agents/skills/research-helper/SKILL.md +11 -0
  52. package/examples/openclaw-workspace/skills/notes/SKILL.md +3 -0
  53. package/examples/openclaw-workspace/skills/research-helper/SKILL.md +17 -0
  54. package/examples/risky-mcp-config/.cursor/mcp.json +29 -0
  55. package/examples/risky-openclaw-plugin/openclaw.plugin.json +6 -0
  56. package/examples/risky-openclaw-plugin/package.json +7 -0
  57. package/examples/risky-openclaw-plugin/src/index.ts +1 -0
  58. package/examples/risky-skill/SKILL.md +17 -0
  59. package/examples/safe-mcp-config/.cursor/mcp.json +15 -0
  60. package/examples/safe-openclaw-plugin/dist/index.js +1 -0
  61. package/examples/safe-openclaw-plugin/openclaw.plugin.json +5 -0
  62. package/examples/safe-openclaw-plugin/package.json +14 -0
  63. package/examples/safe-skill/SKILL.md +12 -0
  64. package/package.json +49 -0
  65. package/schemas/clawguard-report.schema.json +266 -0
  66. package/scripts/capture-demo.js +206 -0
  67. package/src/clawhub.js +383 -0
  68. package/src/cli.js +296 -0
  69. package/src/config.js +205 -0
  70. package/src/dependencies.js +417 -0
  71. package/src/mcp-config.js +592 -0
  72. package/src/policy.js +165 -0
  73. package/src/reporters/html.js +482 -0
  74. package/src/reporters/sarif.js +121 -0
  75. package/src/rule-catalog.js +400 -0
  76. package/src/rules.js +121 -0
  77. package/src/scanner.js +387 -0
  78. package/src/skill-metadata.js +516 -0
  79. package/src/web-server.js +395 -0
  80. package/src/workspace.js +233 -0
  81. package/web/app.js +374 -0
  82. package/web/index.html +119 -0
  83. package/web/styles.css +453 -0
package/src/config.js ADDED
@@ -0,0 +1,205 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { defaultScanOptions } from "./scanner.js";
4
+ import { normalizePolicyPreset } from "./policy.js";
5
+
6
+ export const defaultConfig = {
7
+ policy: "personal",
8
+ failOn: "critical",
9
+ failOnPolicy: false,
10
+ policyFailOn: "manual_review",
11
+ maxFileSizeBytes: defaultScanOptions.maxFileSizeBytes,
12
+ maxFindingsPerRulePerFile: defaultScanOptions.maxFindingsPerRulePerFile,
13
+ suppressions: []
14
+ };
15
+
16
+ const failLevels = new Set(["none", "low", "medium", "high", "critical"]);
17
+ const policyDecisions = new Set(["warn", "manual_review", "sandbox_required", "dual_approval", "block"]);
18
+
19
+ export async function loadConfig(targetPath = ".", configPath = null) {
20
+ const resolvedConfigPath = configPath
21
+ ? path.resolve(configPath)
22
+ : await findConfigPath(targetPath);
23
+
24
+ if (!resolvedConfigPath) {
25
+ return {
26
+ path: null,
27
+ config: { ...defaultConfig }
28
+ };
29
+ }
30
+
31
+ let parsed;
32
+
33
+ try {
34
+ parsed = JSON.parse(await fs.readFile(resolvedConfigPath, "utf8"));
35
+ } catch (error) {
36
+ throw new Error(`Unable to read config ${resolvedConfigPath}: ${error.message}`);
37
+ }
38
+
39
+ return {
40
+ path: resolvedConfigPath,
41
+ config: normalizeConfig(parsed, resolvedConfigPath)
42
+ };
43
+ }
44
+
45
+ export function mergeConfig(config, cliOptions = {}) {
46
+ const normalized = normalizeConfig({
47
+ ...config,
48
+ ...definedOnly({
49
+ policy: cliOptions.policy,
50
+ failOn: cliOptions.failOn,
51
+ failOnPolicy: cliOptions.failOnPolicy,
52
+ policyFailOn: cliOptions.policyFailOn,
53
+ maxFileSizeBytes: cliOptions.maxFileSizeBytes,
54
+ maxFindingsPerRulePerFile: cliOptions.maxFindingsPerRulePerFile
55
+ })
56
+ });
57
+
58
+ return {
59
+ ...normalized,
60
+ target: cliOptions.target ?? ".",
61
+ json: Boolean(cliOptions.json),
62
+ configPath: cliOptions.configPath,
63
+ htmlPath: cliOptions.htmlPath,
64
+ sarifPath: cliOptions.sarifPath
65
+ };
66
+ }
67
+
68
+ export function normalizeConfig(config = {}, source = "config") {
69
+ const normalized = {
70
+ ...defaultConfig,
71
+ ...config
72
+ };
73
+
74
+ normalized.policy = normalizePolicyPreset(normalized.policy);
75
+
76
+ if (!failLevels.has(normalized.failOn)) {
77
+ throw new Error(`Invalid failOn in ${source}. Use one of: ${[...failLevels].join(", ")}`);
78
+ }
79
+
80
+ if (!policyDecisions.has(normalized.policyFailOn)) {
81
+ throw new Error(`Invalid policyFailOn in ${source}. Use one of: ${[...policyDecisions].join(", ")}`);
82
+ }
83
+
84
+ normalized.failOnPolicy = Boolean(normalized.failOnPolicy);
85
+ normalized.maxFileSizeBytes = normalizeSize(normalized.maxFileSizeBytes, "maxFileSizeBytes", source);
86
+ normalized.maxFindingsPerRulePerFile = normalizePositiveInteger(
87
+ normalized.maxFindingsPerRulePerFile,
88
+ "maxFindingsPerRulePerFile",
89
+ source
90
+ );
91
+ normalized.suppressions = normalizeSuppressions(normalized.suppressions, source);
92
+
93
+ return normalized;
94
+ }
95
+
96
+ export function parseSize(value) {
97
+ if (typeof value === "number") {
98
+ return value;
99
+ }
100
+
101
+ if (!value) {
102
+ throw new Error("Missing size value");
103
+ }
104
+
105
+ const match = /^(\d+)(b|kb|mb)?$/i.exec(String(value).trim());
106
+ if (!match) {
107
+ throw new Error("Use bytes, kb, or mb.");
108
+ }
109
+
110
+ const amount = Number(match[1]);
111
+ if (amount <= 0) {
112
+ throw new Error("Size must be greater than 0.");
113
+ }
114
+
115
+ const unit = (match[2] ?? "b").toLowerCase();
116
+ const multipliers = {
117
+ b: 1,
118
+ kb: 1024,
119
+ mb: 1024 * 1024
120
+ };
121
+
122
+ return amount * multipliers[unit];
123
+ }
124
+
125
+ async function findConfigPath(targetPath) {
126
+ const resolvedTarget = path.resolve(targetPath);
127
+ let stats;
128
+
129
+ try {
130
+ stats = await fs.lstat(resolvedTarget);
131
+ } catch {
132
+ return null;
133
+ }
134
+
135
+ let currentDir = stats.isDirectory() ? resolvedTarget : path.dirname(resolvedTarget);
136
+
137
+ while (true) {
138
+ const candidate = path.join(currentDir, ".clawguard.json");
139
+
140
+ try {
141
+ const candidateStats = await fs.lstat(candidate);
142
+ if (candidateStats.isFile()) {
143
+ return candidate;
144
+ }
145
+ } catch {
146
+ // Keep walking upward.
147
+ }
148
+
149
+ const parent = path.dirname(currentDir);
150
+ if (parent === currentDir) {
151
+ return null;
152
+ }
153
+ currentDir = parent;
154
+ }
155
+ }
156
+
157
+ function normalizeSize(value, name, source) {
158
+ try {
159
+ const size = parseSize(value);
160
+ if (!Number.isSafeInteger(size) || size <= 0) {
161
+ throw new Error("Size must be a positive integer.");
162
+ }
163
+ return size;
164
+ } catch (error) {
165
+ throw new Error(`Invalid ${name} in ${source}: ${error.message}`);
166
+ }
167
+ }
168
+
169
+ function normalizePositiveInteger(value, name, source) {
170
+ const number = Number(value);
171
+
172
+ if (!Number.isSafeInteger(number) || number <= 0) {
173
+ throw new Error(`Invalid ${name} in ${source}: expected a positive integer.`);
174
+ }
175
+
176
+ return number;
177
+ }
178
+
179
+ function normalizeSuppressions(suppressions, source) {
180
+ if (!Array.isArray(suppressions)) {
181
+ throw new Error(`Invalid suppressions in ${source}: expected an array.`);
182
+ }
183
+
184
+ return suppressions.map((suppression, index) => {
185
+ if (!suppression || typeof suppression !== "object") {
186
+ throw new Error(`Invalid suppression ${index} in ${source}: expected an object.`);
187
+ }
188
+
189
+ if (!suppression.ruleId || !suppression.reason) {
190
+ throw new Error(`Invalid suppression ${index} in ${source}: ruleId and reason are required.`);
191
+ }
192
+
193
+ return {
194
+ ruleId: String(suppression.ruleId),
195
+ path: suppression.path ? String(suppression.path) : null,
196
+ reason: String(suppression.reason),
197
+ expires: suppression.expires ? String(suppression.expires) : null,
198
+ allowCritical: Boolean(suppression.allowCritical)
199
+ };
200
+ });
201
+ }
202
+
203
+ function definedOnly(values) {
204
+ return Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined));
205
+ }
@@ -0,0 +1,417 @@
1
+ import path from "node:path";
2
+
3
+ const npmLockNames = new Set(["package-lock.json", "pnpm-lock.yaml", "yarn.lock"]);
4
+ const dependencyFields = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"];
5
+ const installLifecycleScripts = new Set(["preinstall", "install", "postinstall", "prepare", "prepublish", "prepublishOnly"]);
6
+ const suspiciousNameTerms = ["backdoor", "credential", "exfil", "keylogger", "malware", "password", "secret", "stealer", "token"];
7
+
8
+ export function analyzeDependencyManifests(fileRecords, basePath = process.cwd()) {
9
+ const findings = [];
10
+ const records = fileRecords.filter((record) => isDependencyFile(record.file));
11
+ const manifests = [];
12
+ const lockfiles = records.filter((record) => isDependencyLockfile(record.file)).map((record) => ({
13
+ file: relativePath(basePath, record.file),
14
+ ecosystem: lockfileEcosystem(record.file),
15
+ directory: toPosixPath(path.dirname(relativePath(basePath, record.file)))
16
+ }));
17
+
18
+ for (const record of records) {
19
+ const name = path.basename(record.file);
20
+
21
+ if (name === "package.json") {
22
+ const manifest = parsePackageJson(record, basePath, findings);
23
+ if (manifest) {
24
+ manifests.push(manifest);
25
+ findings.push(...analyzePackageManifest(manifest, lockfiles));
26
+ }
27
+ continue;
28
+ }
29
+
30
+ if (name === "requirements.txt") {
31
+ const manifest = parseRequirements(record, basePath);
32
+ manifests.push(manifest);
33
+ findings.push(...analyzeDependencyEntries(manifest));
34
+ continue;
35
+ }
36
+
37
+ if (name === "pyproject.toml") {
38
+ const manifest = parsePyproject(record, basePath);
39
+ manifests.push(manifest);
40
+ findings.push(...analyzeDependencyEntries(manifest));
41
+ }
42
+ }
43
+
44
+ return {
45
+ findings: dedupeFindings(findings),
46
+ dependencies: {
47
+ manifests: manifests.map(publicManifest),
48
+ lockfiles
49
+ }
50
+ };
51
+ }
52
+
53
+ export function isDependencyFile(filePath) {
54
+ const name = path.basename(filePath);
55
+ return ["package.json", "package-lock.json", "pnpm-lock.yaml", "yarn.lock", "requirements.txt", "pyproject.toml"].includes(name);
56
+ }
57
+
58
+ function parsePackageJson(record, basePath, findings) {
59
+ let parsed;
60
+
61
+ try {
62
+ parsed = JSON.parse(record.text);
63
+ } catch (error) {
64
+ findings.push(createFinding({
65
+ ruleId: "invalid-dependency-manifest",
66
+ title: "Dependency manifest is not valid JSON",
67
+ severity: "medium",
68
+ recommendation: "Fix invalid package.json before trusting dependency and install-script metadata.",
69
+ file: relativePath(basePath, record.file),
70
+ line: 1,
71
+ evidence: error.message
72
+ }));
73
+ return null;
74
+ }
75
+
76
+ const file = relativePath(basePath, record.file);
77
+ const directory = toPosixPath(path.dirname(file));
78
+ const dependencies = [];
79
+
80
+ for (const field of dependencyFields) {
81
+ const values = parsed[field];
82
+ if (!values || typeof values !== "object" || Array.isArray(values)) {
83
+ continue;
84
+ }
85
+
86
+ for (const [name, spec] of Object.entries(values)) {
87
+ dependencies.push({
88
+ name,
89
+ spec: String(spec ?? ""),
90
+ group: field,
91
+ file,
92
+ line: lineForPackageJsonKey(record.text, name)
93
+ });
94
+ }
95
+ }
96
+
97
+ const scripts = [];
98
+ if (parsed.scripts && typeof parsed.scripts === "object" && !Array.isArray(parsed.scripts)) {
99
+ for (const [name, command] of Object.entries(parsed.scripts)) {
100
+ scripts.push({
101
+ name,
102
+ command: String(command ?? ""),
103
+ file,
104
+ line: lineForPackageJsonKey(record.text, name)
105
+ });
106
+ }
107
+ }
108
+
109
+ return {
110
+ ecosystem: "npm",
111
+ file,
112
+ directory,
113
+ name: String(parsed.name ?? ""),
114
+ dependencyCount: dependencies.length,
115
+ dependencies,
116
+ scripts
117
+ };
118
+ }
119
+
120
+ function parseRequirements(record, basePath) {
121
+ const file = relativePath(basePath, record.file);
122
+ const dependencies = [];
123
+ const lines = record.text.split(/\r?\n/);
124
+
125
+ for (let index = 0; index < lines.length; index += 1) {
126
+ const raw = lines[index].trim();
127
+ if (!raw || raw.startsWith("#") || raw.startsWith("-")) {
128
+ continue;
129
+ }
130
+
131
+ const value = raw.split(/\s+#/)[0].trim();
132
+ const directMatch = /^(.+?)\s@\s(.+)$/.exec(value);
133
+ const direct = /^(?:git\+|https?:\/\/|file:)/i.test(value) || Boolean(directMatch);
134
+ const [namePart, specPart = ""] = value.split(/===|==|~=|!=|<=|>=|<|>/);
135
+ const operator = /===|==|~=|!=|<=|>=|<|>/.exec(value)?.[0] ?? "";
136
+
137
+ dependencies.push({
138
+ name: cleanPackageName(directMatch?.[1] ?? namePart),
139
+ spec: direct ? cleanTomlString(directMatch?.[2] ?? value) : `${operator}${specPart}`.trim(),
140
+ group: "requirements",
141
+ file,
142
+ line: index + 1
143
+ });
144
+ }
145
+
146
+ return {
147
+ ecosystem: "python",
148
+ file,
149
+ directory: toPosixPath(path.dirname(file)),
150
+ name: "",
151
+ dependencyCount: dependencies.length,
152
+ dependencies,
153
+ scripts: []
154
+ };
155
+ }
156
+
157
+ function parsePyproject(record, basePath) {
158
+ const file = relativePath(basePath, record.file);
159
+ const dependencies = [];
160
+ const lines = record.text.split(/\r?\n/);
161
+ let currentSection = "";
162
+ let inProjectDependencies = false;
163
+
164
+ for (let index = 0; index < lines.length; index += 1) {
165
+ const line = lines[index].trim();
166
+
167
+ if (!line || line.startsWith("#")) {
168
+ continue;
169
+ }
170
+
171
+ const sectionMatch = /^\[([^\]]+)\]$/.exec(line);
172
+ if (sectionMatch) {
173
+ currentSection = sectionMatch[1].toLowerCase();
174
+ inProjectDependencies = false;
175
+ continue;
176
+ }
177
+
178
+ if (currentSection === "project" && /^dependencies\s*=\s*\[$/.test(line)) {
179
+ inProjectDependencies = true;
180
+ continue;
181
+ }
182
+
183
+ if (inProjectDependencies) {
184
+ if (line.startsWith("]")) {
185
+ inProjectDependencies = false;
186
+ continue;
187
+ }
188
+ addPythonDependency(dependencies, cleanTomlString(line.replace(/,$/, "")), file, index + 1);
189
+ continue;
190
+ }
191
+
192
+ if (currentSection === "tool.poetry.dependencies") {
193
+ const dependencyMatch = /^([A-Za-z0-9_.-]+)\s*=\s*(.+)$/.exec(line);
194
+ if (dependencyMatch && dependencyMatch[1].toLowerCase() !== "python") {
195
+ dependencies.push({
196
+ name: dependencyMatch[1],
197
+ spec: cleanTomlString(dependencyMatch[2]),
198
+ group: "tool.poetry.dependencies",
199
+ file,
200
+ line: index + 1
201
+ });
202
+ }
203
+ }
204
+ }
205
+
206
+ return {
207
+ ecosystem: "python",
208
+ file,
209
+ directory: toPosixPath(path.dirname(file)),
210
+ name: "",
211
+ dependencyCount: dependencies.length,
212
+ dependencies,
213
+ scripts: []
214
+ };
215
+ }
216
+
217
+ function analyzePackageManifest(manifest, lockfiles) {
218
+ const findings = [];
219
+
220
+ for (const script of manifest.scripts) {
221
+ if (installLifecycleScripts.has(script.name)) {
222
+ findings.push(createFinding({
223
+ ruleId: "dependency-install-script",
224
+ title: "Dependency manifest defines an install lifecycle script",
225
+ severity: "high",
226
+ recommendation: "Review install-time scripts carefully and prefer dependencies that do not execute code during installation.",
227
+ file: script.file,
228
+ line: script.line,
229
+ evidence: `${script.name}: ${script.command}`
230
+ }));
231
+ }
232
+ }
233
+
234
+ if (manifest.dependencyCount > 0 && !hasNpmLockfile(manifest.directory, lockfiles)) {
235
+ findings.push(createFinding({
236
+ ruleId: "dependency-lockfile-missing",
237
+ title: "Dependency manifest has no matching lockfile",
238
+ severity: "medium",
239
+ recommendation: "Commit a package lockfile so dependency resolution is deterministic before publishing or installing the skill.",
240
+ file: manifest.file,
241
+ line: 1,
242
+ evidence: `${manifest.file} declares ${manifest.dependencyCount} dependencies`
243
+ }));
244
+ }
245
+
246
+ findings.push(...analyzeDependencyEntries(manifest));
247
+ return findings;
248
+ }
249
+
250
+ function analyzeDependencyEntries(manifest) {
251
+ const findings = [];
252
+
253
+ for (const dependency of manifest.dependencies) {
254
+ if (isSuspiciousName(dependency.name)) {
255
+ findings.push(createFinding({
256
+ ruleId: "dependency-suspicious-name",
257
+ title: "Dependency name contains suspicious security-sensitive terms",
258
+ severity: "medium",
259
+ recommendation: "Review the dependency name, source, and maintainers before trusting this skill bundle.",
260
+ file: dependency.file,
261
+ line: dependency.line,
262
+ evidence: `${dependency.name}@${dependency.spec}`
263
+ }));
264
+ }
265
+
266
+ if (isDirectSourceSpec(dependency.spec)) {
267
+ findings.push(createFinding({
268
+ ruleId: "dependency-direct-source",
269
+ title: "Dependency is installed from a direct URL or Git source",
270
+ severity: "high",
271
+ recommendation: "Prefer registry packages with pinned versions, or manually verify the referenced source and commit.",
272
+ file: dependency.file,
273
+ line: dependency.line,
274
+ evidence: `${dependency.name}@${dependency.spec}`
275
+ }));
276
+ continue;
277
+ }
278
+
279
+ if (!isPinnedSpec(dependency.spec, manifest.ecosystem)) {
280
+ findings.push(createFinding({
281
+ ruleId: "dependency-unpinned-spec",
282
+ title: "Dependency version is not pinned",
283
+ severity: "medium",
284
+ recommendation: "Pin exact dependency versions before publishing, installing, or recommending the skill.",
285
+ file: dependency.file,
286
+ line: dependency.line,
287
+ evidence: `${dependency.name}@${dependency.spec || "unversioned"}`
288
+ }));
289
+ }
290
+ }
291
+
292
+ return findings;
293
+ }
294
+
295
+ function hasNpmLockfile(directory, lockfiles) {
296
+ return lockfiles.some((lockfile) => lockfile.directory === directory && lockfile.ecosystem === "npm");
297
+ }
298
+
299
+ export function isDependencyLockfile(filePath) {
300
+ return npmLockNames.has(path.basename(filePath));
301
+ }
302
+
303
+ function lockfileEcosystem(filePath) {
304
+ return npmLockNames.has(path.basename(filePath)) ? "npm" : "unknown";
305
+ }
306
+
307
+ function isPinnedSpec(spec, ecosystem) {
308
+ const value = String(spec ?? "").trim();
309
+
310
+ if (!value) {
311
+ return false;
312
+ }
313
+
314
+ if (ecosystem === "python") {
315
+ return /^={2,3}\s*[0-9][A-Za-z0-9.+!_-]*$/.test(value);
316
+ }
317
+
318
+ return /^v?\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.]+)?$/.test(value);
319
+ }
320
+
321
+ function isDirectSourceSpec(spec) {
322
+ const value = String(spec ?? "").trim();
323
+ return /^(?:git\+|git:|github:|https?:\/\/|file:)/i.test(value) || /\s@\s(?:git\+|https?:\/\/|file:)/i.test(value);
324
+ }
325
+
326
+ function isSuspiciousName(name) {
327
+ const normalized = String(name ?? "").toLowerCase();
328
+ return suspiciousNameTerms.some((term) => normalized.includes(term));
329
+ }
330
+
331
+ function addPythonDependency(dependencies, value, file, line) {
332
+ if (!value) {
333
+ return;
334
+ }
335
+
336
+ const directMatch = /^(.+?)\s@\s(.+)$/.exec(value);
337
+ const direct = /^(?:git\+|https?:\/\/|file:)/i.test(value) || Boolean(directMatch);
338
+ const [namePart, specPart = ""] = value.split(/===|==|~=|!=|<=|>=|<|>/);
339
+ const operator = /===|==|~=|!=|<=|>=|<|>/.exec(value)?.[0] ?? "";
340
+
341
+ dependencies.push({
342
+ name: cleanPackageName(directMatch?.[1] ?? namePart),
343
+ spec: direct ? cleanTomlString(directMatch?.[2] ?? value) : `${operator}${specPart}`.trim(),
344
+ group: "project.dependencies",
345
+ file,
346
+ line
347
+ });
348
+ }
349
+
350
+ function publicManifest(manifest) {
351
+ return {
352
+ ecosystem: manifest.ecosystem,
353
+ file: manifest.file,
354
+ directory: manifest.directory,
355
+ name: manifest.name,
356
+ dependencyCount: manifest.dependencyCount,
357
+ scriptCount: manifest.scripts.length
358
+ };
359
+ }
360
+
361
+ function lineForPackageJsonKey(text, key) {
362
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
363
+ const matcher = new RegExp(`"${escaped}"\\s*:`);
364
+ const match = matcher.exec(text);
365
+ return match ? text.slice(0, match.index).split("\n").length : 1;
366
+ }
367
+
368
+ function cleanPackageName(value) {
369
+ return cleanTomlString(value)
370
+ .replace(/\[[^\]]+\]/g, "")
371
+ .trim();
372
+ }
373
+
374
+ function cleanTomlString(value) {
375
+ return String(value ?? "")
376
+ .trim()
377
+ .replace(/^["']+|["',]+$/g, "")
378
+ .trim();
379
+ }
380
+
381
+ function dedupeFindings(findings) {
382
+ const seen = new Set();
383
+ const unique = [];
384
+
385
+ for (const finding of findings) {
386
+ const key = `${finding.ruleId}:${finding.file}:${finding.line}:${finding.evidence}`;
387
+ if (seen.has(key)) {
388
+ continue;
389
+ }
390
+
391
+ seen.add(key);
392
+ unique.push(finding);
393
+ }
394
+
395
+ return unique;
396
+ }
397
+
398
+ function createFinding({ ruleId, title, severity, recommendation, file, line, evidence }) {
399
+ return {
400
+ ruleId,
401
+ title,
402
+ severity,
403
+ recommendation,
404
+ file,
405
+ line,
406
+ evidence
407
+ };
408
+ }
409
+
410
+ function toPosixPath(value) {
411
+ return String(value ?? "").split(path.sep).join("/");
412
+ }
413
+
414
+ function relativePath(basePath, filePath) {
415
+ const relative = path.relative(basePath, filePath);
416
+ return relative || path.basename(filePath);
417
+ }