@cassiomc1/forgeloop 1.2.1 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.cursor/rules/project-loop.mdc +1 -1
- package/.github/copilot-instructions.md +1 -1
- package/AGENTS.md +1 -1
- package/CLAUDE.md +1 -1
- package/DOCS_INDEX.md +3 -0
- package/ENG/design-code-eng.md +59 -0
- package/ENG/premium-sites-studio-eng.md +28 -0
- package/LOOP_ENGINEERING.md +23 -0
- package/LOOP_SYSTEM_DESIGN.md +9 -5
- package/ORCHESTRATOR_INTEGRATION.md +37 -4
- package/PROTOCOL_INTEGRATION.md +13 -0
- package/README.md +34 -2
- package/TERMINOLOGY.md +10 -0
- package/THIRD_PARTY_NOTICES.md +34 -0
- package/THREAT_MODEL.md +12 -1
- package/docs/ARTIFACT_REFERENCE.md +150 -0
- package/docs/CLI_REFERENCE.md +263 -30
- package/docs/CROSS_HARNESS_CONTINUITY.md +1 -0
- package/docs/DOCUMENTATION_GUIDE.md +41 -4
- package/docs/GETTING_STARTED.md +9 -4
- package/docs/RECIPES.md +31 -1
- package/docs/TROUBLESHOOTING.md +191 -0
- package/package.json +1 -1
- package/schemas/policy-baseline.schema.json +26 -0
- package/schemas/policy-discovery.schema.json +45 -0
- package/schemas/policy-lock.schema.json +16 -0
- package/schemas/policy-rules.schema.json +48 -0
- package/schemas/policy-snapshot.schema.json +16 -0
- package/src/cli.js +69 -1
- package/src/commands/baseline.js +120 -0
- package/src/commands/init.js +304 -6
- package/src/commands/policy-diff.js +51 -0
- package/src/commands/policy-discover.js +42 -0
- package/src/commands/policy-status.js +33 -0
- package/src/commands/profile-interview.js +50 -0
- package/src/commands/reconcile-closure.js +49 -0
- package/src/commands/rule-verify.js +36 -0
- package/src/commands/validate-receipt.js +38 -3
- package/src/core/artifact-registry.js +60 -0
- package/src/core/audit.js +24 -0
- package/src/core/cli-command-definitions.js +114 -7
- package/src/core/cli-metadata.js +1 -1
- package/src/core/completion-artifacts.js +29 -3
- package/src/core/completion.js +101 -10
- package/src/core/error-codes.js +227 -0
- package/src/core/events.js +22 -0
- package/src/core/execution-prerequisites.js +38 -20
- package/src/core/execution.js +20 -3
- package/src/core/native-adapters.js +14 -4
- package/src/core/next-action-model.js +9 -0
- package/src/core/next-action.js +128 -82
- package/src/core/policy-adapters.js +276 -0
- package/src/core/policy-baseline.js +144 -0
- package/src/core/policy-diff.js +133 -0
- package/src/core/policy-discovery.js +225 -0
- package/src/core/policy-engine.js +533 -0
- package/src/core/policy-mutation.js +139 -0
- package/src/core/preflight-consistency.js +23 -15
- package/src/core/preflight.js +65 -1
- package/src/core/reconcile-closure.js +173 -0
- package/src/core/schema-validation.js +6 -0
- package/src/core/task-context.js +11 -0
- package/src/core/task-discovery.js +67 -1
- package/src/core/task-paths.js +9 -0
- package/src/core/templates.js +5 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileExists } from "./filesystem.js";
|
|
4
|
+
import { sha256 } from "./manifest.js";
|
|
5
|
+
|
|
6
|
+
const SECRET_PATTERNS = [
|
|
7
|
+
{ name: "AWS Access Key", pattern: /AKIA[0-9A-Z]{16}/ },
|
|
8
|
+
{ name: "Private Key", pattern: /-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/ },
|
|
9
|
+
{ name: "GitHub Token", pattern: /gh[pousr]_[A-Za-z0-9_]{36,255}/ },
|
|
10
|
+
{ name: "Generic API Key", pattern: /(?:api_key|apikey|secret_key|auth_token)\s*[:=]\s*["'][A-Za-z0-9_\-]{20,}["']/i },
|
|
11
|
+
{ name: "Password Assignment", pattern: /(?:password|passwd|pwd)\s*[:=]\s*["'][^"'\s]{8,}["']/i },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const IGNORED_SECRET_FILES = [
|
|
15
|
+
"package-lock.json",
|
|
16
|
+
"pnpm-lock.yaml",
|
|
17
|
+
"yarn.lock",
|
|
18
|
+
".forgeloop/policy/baseline.json",
|
|
19
|
+
".forgeloop/policy/policy.lock",
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const IGNORED_DIRECTORIES = new Set([
|
|
23
|
+
"node_modules",
|
|
24
|
+
".git",
|
|
25
|
+
".forgeloop",
|
|
26
|
+
"dist",
|
|
27
|
+
"build",
|
|
28
|
+
"coverage",
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
async function collectFiles(directory, baseDir = directory) {
|
|
32
|
+
const files = [];
|
|
33
|
+
try {
|
|
34
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
35
|
+
for (const entry of entries) {
|
|
36
|
+
if (IGNORED_DIRECTORIES.has(entry.name)) continue;
|
|
37
|
+
const fullPath = path.join(directory, entry.name);
|
|
38
|
+
if (entry.isDirectory()) {
|
|
39
|
+
const nested = await collectFiles(fullPath, baseDir);
|
|
40
|
+
files.push(...nested);
|
|
41
|
+
} else if (entry.isFile()) {
|
|
42
|
+
files.push(path.relative(baseDir, fullPath));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
// If directory not readable, return collected
|
|
47
|
+
}
|
|
48
|
+
return files;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const BUILTIN_ADAPTERS = Object.freeze({
|
|
52
|
+
["secret-detection"]: {
|
|
53
|
+
id: "secret-detection",
|
|
54
|
+
description: "Scans repository files for hardcoded credentials, tokens, and private keys.",
|
|
55
|
+
supports: () => true,
|
|
56
|
+
check: async ({ target, files = null, contentOverrides = null } = {}) => {
|
|
57
|
+
const targetFiles = files ?? (await collectFiles(target));
|
|
58
|
+
const violations = [];
|
|
59
|
+
let scannedFileCount = 0;
|
|
60
|
+
|
|
61
|
+
for (const relPath of targetFiles) {
|
|
62
|
+
if (IGNORED_SECRET_FILES.some((ignored) => relPath.endsWith(ignored))) continue;
|
|
63
|
+
// Only inspect text/source files
|
|
64
|
+
const ext = path.extname(relPath).toLowerCase();
|
|
65
|
+
const textExtensions = [".js", ".mjs", ".ts", ".jsx", ".tsx", ".py", ".rs", ".go", ".json", ".yaml", ".yml", ".toml", ".env", ".md", ".sh"];
|
|
66
|
+
if (ext && !textExtensions.includes(ext)) continue;
|
|
67
|
+
|
|
68
|
+
let content;
|
|
69
|
+
if (contentOverrides && contentOverrides[relPath] !== undefined) {
|
|
70
|
+
content = contentOverrides[relPath];
|
|
71
|
+
} else {
|
|
72
|
+
try {
|
|
73
|
+
content = await readFile(path.join(target, relPath), "utf8");
|
|
74
|
+
} catch {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
scannedFileCount += 1;
|
|
80
|
+
const lines = content.split("\n");
|
|
81
|
+
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
|
|
82
|
+
const line = lines[lineIndex];
|
|
83
|
+
for (const { name, pattern } of SECRET_PATTERNS) {
|
|
84
|
+
if (pattern.test(line)) {
|
|
85
|
+
// Ignore test fixtures or documentation examples that explicitly mention dummy/fake/example
|
|
86
|
+
if (line.includes("EXAMPLE") || line.includes("fake_") || line.includes("placeholder")) continue;
|
|
87
|
+
const snippet = line.trim().slice(0, 80);
|
|
88
|
+
const fingerprint = sha256(`SECURITY.NO_HARDCODED_SECRET:${relPath}:${lineIndex + 1}:${snippet}`);
|
|
89
|
+
violations.push({
|
|
90
|
+
ruleId: "SECURITY.NO_HARDCODED_SECRET",
|
|
91
|
+
file: relPath,
|
|
92
|
+
line: lineIndex + 1,
|
|
93
|
+
snippet,
|
|
94
|
+
fingerprint: `sha256:${fingerprint}`,
|
|
95
|
+
message: `Potential hardcoded secret (${name}) detected at ${relPath}:${lineIndex + 1}`,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
passed: violations.length === 0,
|
|
104
|
+
scannedFiles: scannedFileCount,
|
|
105
|
+
isInert: scannedFileCount === 0,
|
|
106
|
+
violations,
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
"grain-complexity": {
|
|
112
|
+
id: "grain-complexity",
|
|
113
|
+
description: "Evaluates file and method complexity against configured thresholds.",
|
|
114
|
+
supports: () => true,
|
|
115
|
+
check: async ({ target, rule, files = null, contentOverrides = null } = {}) => {
|
|
116
|
+
const threshold = rule?.check?.threshold ?? rule?.parameters?.threshold ?? 15;
|
|
117
|
+
const targetFiles = files ?? (await collectFiles(target));
|
|
118
|
+
const sourceExtensions = [".js", ".mjs", ".ts", ".jsx", ".tsx", ".py", ".rs", ".go"];
|
|
119
|
+
const relevantFiles = targetFiles.filter((f) => sourceExtensions.includes(path.extname(f).toLowerCase()));
|
|
120
|
+
const violations = [];
|
|
121
|
+
let checkedCount = 0;
|
|
122
|
+
|
|
123
|
+
for (const relPath of relevantFiles) {
|
|
124
|
+
let content;
|
|
125
|
+
if (contentOverrides && contentOverrides[relPath] !== undefined) {
|
|
126
|
+
content = contentOverrides[relPath];
|
|
127
|
+
} else {
|
|
128
|
+
try {
|
|
129
|
+
content = await readFile(path.join(target, relPath), "utf8");
|
|
130
|
+
} catch {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
checkedCount += 1;
|
|
136
|
+
const lines = content.split("\n");
|
|
137
|
+
let nestingLevel = 0;
|
|
138
|
+
let maxNesting = 0;
|
|
139
|
+
|
|
140
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
141
|
+
const trimmed = lines[i].trim();
|
|
142
|
+
const opens = (trimmed.match(/{/g) || []).length;
|
|
143
|
+
const closes = (trimmed.match(/}/g) || []).length;
|
|
144
|
+
nestingLevel += opens - closes;
|
|
145
|
+
if (nestingLevel > maxNesting) maxNesting = nestingLevel;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Check if complexity metric exceeds threshold
|
|
149
|
+
if (maxNesting > threshold) {
|
|
150
|
+
const fingerprint = sha256(`GRAIN.MAX_COMPLEXITY:${relPath}:${maxNesting}`);
|
|
151
|
+
violations.push({
|
|
152
|
+
ruleId: rule?.id ?? "GRAIN.MAX_COMPLEXITY",
|
|
153
|
+
file: relPath,
|
|
154
|
+
observed: maxNesting,
|
|
155
|
+
threshold,
|
|
156
|
+
fingerprint: `sha256:${fingerprint}`,
|
|
157
|
+
message: `Complexity ${maxNesting} exceeds threshold of ${threshold} in ${relPath}`,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
passed: violations.length === 0,
|
|
164
|
+
scannedFiles: checkedCount,
|
|
165
|
+
isInert: checkedCount === 0,
|
|
166
|
+
violations,
|
|
167
|
+
};
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
"architecture-layers": {
|
|
172
|
+
id: "architecture-layers",
|
|
173
|
+
description: "Enforces clean layered dependency boundaries (e.g. domain cannot depend on infrastructure).",
|
|
174
|
+
supports: () => true,
|
|
175
|
+
check: async ({ target, rule, files = null, contentOverrides = null } = {}) => {
|
|
176
|
+
const targetFiles = files ?? (await collectFiles(target));
|
|
177
|
+
const sourceExtensions = [".js", ".mjs", ".ts", ".jsx", ".tsx", ".py", ".rs", ".go"];
|
|
178
|
+
const domainFiles = targetFiles.filter((f) => {
|
|
179
|
+
const norm = f.replace(/\\/g, "/");
|
|
180
|
+
return (norm.startsWith("src/domain/") || norm.startsWith("domain/")) && sourceExtensions.includes(path.extname(f).toLowerCase());
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const violations = [];
|
|
184
|
+
let checkedCount = 0;
|
|
185
|
+
|
|
186
|
+
for (const relPath of domainFiles) {
|
|
187
|
+
let content;
|
|
188
|
+
if (contentOverrides && contentOverrides[relPath] !== undefined) {
|
|
189
|
+
content = contentOverrides[relPath];
|
|
190
|
+
} else {
|
|
191
|
+
try {
|
|
192
|
+
content = await readFile(path.join(target, relPath), "utf8");
|
|
193
|
+
} catch {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
checkedCount += 1;
|
|
199
|
+
const lines = content.split("\n");
|
|
200
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
201
|
+
const line = lines[i];
|
|
202
|
+
if (/import\s+.*from\s+['"].*(?:infrastructure|infra|database|db)['"]/i.test(line) ||
|
|
203
|
+
/require\(['"].*(?:infrastructure|infra|database|db)['"]\)/i.test(line)) {
|
|
204
|
+
const snippet = line.trim();
|
|
205
|
+
const fingerprint = sha256(`ARCH.NO_DIRECT_DATABASE_ACCESS:${relPath}:${i + 1}:${snippet}`);
|
|
206
|
+
violations.push({
|
|
207
|
+
ruleId: rule?.id ?? "ARCH.NO_DIRECT_DATABASE_ACCESS",
|
|
208
|
+
file: relPath,
|
|
209
|
+
line: i + 1,
|
|
210
|
+
snippet,
|
|
211
|
+
fingerprint: `sha256:${fingerprint}`,
|
|
212
|
+
message: `Layer boundary violation in ${relPath}:${i + 1}: domain layer importing infrastructure`,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
passed: violations.length === 0,
|
|
220
|
+
scannedFiles: checkedCount,
|
|
221
|
+
isInert: checkedCount === 0,
|
|
222
|
+
violations,
|
|
223
|
+
};
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
"repo-structure": {
|
|
228
|
+
id: "repo-structure",
|
|
229
|
+
description: "Verifies required repository structure and root layout.",
|
|
230
|
+
supports: () => true,
|
|
231
|
+
check: async ({ target, rule } = {}) => {
|
|
232
|
+
const requiredFiles = rule?.parameters?.requiredFiles ?? rule?.check?.parameters?.requiredFiles ?? [];
|
|
233
|
+
const violations = [];
|
|
234
|
+
for (const req of requiredFiles) {
|
|
235
|
+
const full = path.join(target, req);
|
|
236
|
+
const exists = await fileExists(full);
|
|
237
|
+
if (!exists) {
|
|
238
|
+
const fingerprint = sha256(`REPO.STRUCTURE:${req}:missing`);
|
|
239
|
+
violations.push({
|
|
240
|
+
ruleId: rule?.id ?? "REPO.STRUCTURE",
|
|
241
|
+
file: req,
|
|
242
|
+
fingerprint: `sha256:${fingerprint}`,
|
|
243
|
+
message: `Required file or directory missing: ${req}`,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
passed: violations.length === 0,
|
|
250
|
+
scannedFiles: requiredFiles.length,
|
|
251
|
+
isInert: requiredFiles.length === 0,
|
|
252
|
+
violations,
|
|
253
|
+
};
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
"test-runner": {
|
|
258
|
+
id: "test-runner",
|
|
259
|
+
description: "Candidate test command verifier within ForgeLoop command authority boundary.",
|
|
260
|
+
supports: () => true,
|
|
261
|
+
check: async ({ rule } = {}) => {
|
|
262
|
+
// Discovered commands are candidate verifiers, evaluated in verification flow
|
|
263
|
+
return {
|
|
264
|
+
passed: true,
|
|
265
|
+
scannedFiles: 1,
|
|
266
|
+
isInert: false,
|
|
267
|
+
violations: [],
|
|
268
|
+
candidateCommand: rule?.check?.command ?? ["npm", "test"],
|
|
269
|
+
};
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
export function getPolicyAdapter(adapterId) {
|
|
275
|
+
return BUILTIN_ADAPTERS[adapterId] ?? null;
|
|
276
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { fileExists } from "./filesystem.js";
|
|
3
|
+
import { PROJECT_ARTIFACT_PATHS } from "./task-paths.js";
|
|
4
|
+
import { assertJsonLimits } from "./json-safety.js";
|
|
5
|
+
import { assertSchema, readSchema } from "./schema-validation.js";
|
|
6
|
+
import { writeJsonArtifact } from "./artifacts.js";
|
|
7
|
+
import { sha256 } from "./manifest.js";
|
|
8
|
+
|
|
9
|
+
export function computeViolationFingerprint(violation) {
|
|
10
|
+
if (violation.fingerprint) {
|
|
11
|
+
return violation.fingerprint.startsWith("sha256:") ? violation.fingerprint : `sha256:${violation.fingerprint}`;
|
|
12
|
+
}
|
|
13
|
+
const raw = `${violation.ruleId}:${violation.file || ""}:${violation.line || ""}:${violation.snippet || violation.message || ""}`;
|
|
14
|
+
return `sha256:${sha256(raw)}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function readBaseline(target, packageRoot) {
|
|
18
|
+
const relPath = PROJECT_ARTIFACT_PATHS.policyBaseline;
|
|
19
|
+
const fullPath = `${target}/${relPath}`;
|
|
20
|
+
if (!(await fileExists(fullPath))) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
const raw = await readFile(fullPath, "utf8");
|
|
24
|
+
assertJsonLimits(raw, relPath);
|
|
25
|
+
const parsed = JSON.parse(raw);
|
|
26
|
+
const schema = await readSchema("policy-baseline", packageRoot);
|
|
27
|
+
assertSchema(parsed, schema, "policy-baseline");
|
|
28
|
+
return parsed;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function writeBaseline(target, baseline, packageRoot) {
|
|
32
|
+
const relPath = PROJECT_ARTIFACT_PATHS.policyBaseline;
|
|
33
|
+
const schema = await readSchema("policy-baseline", packageRoot);
|
|
34
|
+
assertSchema(baseline, schema, "policy-baseline");
|
|
35
|
+
await writeJsonArtifact(target, relPath, baseline, "policy-baseline", packageRoot);
|
|
36
|
+
return baseline;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createBaselineFromViolations(violations, { createdAt = new Date().toISOString() } = {}) {
|
|
40
|
+
const ruleGroups = new Map();
|
|
41
|
+
for (const v of violations) {
|
|
42
|
+
const ruleId = v.ruleId;
|
|
43
|
+
if (!ruleGroups.has(ruleId)) {
|
|
44
|
+
ruleGroups.set(ruleId, []);
|
|
45
|
+
}
|
|
46
|
+
const fp = computeViolationFingerprint(v);
|
|
47
|
+
ruleGroups.get(ruleId).push(fp);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const entries = [];
|
|
51
|
+
for (const [ruleId, fingerprints] of ruleGroups.entries()) {
|
|
52
|
+
entries.push({
|
|
53
|
+
ruleId,
|
|
54
|
+
fingerprints: [...new Set(fingerprints)].sort(),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
schemaVersion: 1,
|
|
60
|
+
createdAt,
|
|
61
|
+
entries: entries.sort((a, b) => a.ruleId.localeCompare(b.ruleId)),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function evaluateBaselineViolations(baseline, currentViolations, { now = new Date().toISOString() } = {}) {
|
|
66
|
+
if (!baseline || !baseline.entries) {
|
|
67
|
+
return {
|
|
68
|
+
newViolations: currentViolations,
|
|
69
|
+
baselinedViolations: [],
|
|
70
|
+
resolvedViolations: [],
|
|
71
|
+
ratchetedBaseline: null,
|
|
72
|
+
warnings: [],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const baselineMap = new Map();
|
|
77
|
+
for (const entry of baseline.entries) {
|
|
78
|
+
baselineMap.set(entry.ruleId, new Set(entry.fingerprints));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const newViolations = [];
|
|
82
|
+
const baselinedViolations = [];
|
|
83
|
+
const matchedFingerprintsPerRule = new Map();
|
|
84
|
+
|
|
85
|
+
for (const v of currentViolations) {
|
|
86
|
+
const fp = computeViolationFingerprint(v);
|
|
87
|
+
const existing = baselineMap.get(v.ruleId);
|
|
88
|
+
if (existing && existing.has(fp)) {
|
|
89
|
+
baselinedViolations.push(v);
|
|
90
|
+
if (!matchedFingerprintsPerRule.has(v.ruleId)) {
|
|
91
|
+
matchedFingerprintsPerRule.set(v.ruleId, new Set());
|
|
92
|
+
}
|
|
93
|
+
matchedFingerprintsPerRule.get(v.ruleId).add(fp);
|
|
94
|
+
} else {
|
|
95
|
+
newViolations.push(v);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Determine resolved debt and ratcheted baseline
|
|
100
|
+
const resolvedViolations = [];
|
|
101
|
+
const ratchetedEntries = [];
|
|
102
|
+
const warnings = [];
|
|
103
|
+
|
|
104
|
+
for (const entry of baseline.entries) {
|
|
105
|
+
const matched = matchedFingerprintsPerRule.get(entry.ruleId) ?? new Set();
|
|
106
|
+
const remaining = entry.fingerprints.filter((fp) => matched.has(fp));
|
|
107
|
+
const resolved = entry.fingerprints.filter((fp) => !matched.has(fp));
|
|
108
|
+
|
|
109
|
+
for (const r of resolved) {
|
|
110
|
+
resolvedViolations.push({ ruleId: entry.ruleId, fingerprint: r });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (entry.reviewBy && entry.reviewBy <= now.slice(0, 10)) {
|
|
114
|
+
warnings.push({
|
|
115
|
+
code: "BASELINE_REVIEW_DUE",
|
|
116
|
+
ruleId: entry.ruleId,
|
|
117
|
+
reviewBy: entry.reviewBy,
|
|
118
|
+
message: `Baseline review date ${entry.reviewBy} for rule ${entry.ruleId} has expired.`,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (remaining.length > 0) {
|
|
123
|
+
ratchetedEntries.push({
|
|
124
|
+
ruleId: entry.ruleId,
|
|
125
|
+
fingerprints: remaining.sort(),
|
|
126
|
+
...(entry.reviewBy ? { reviewBy: entry.reviewBy } : {}),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const ratchetedBaseline = {
|
|
132
|
+
schemaVersion: baseline.schemaVersion ?? 1,
|
|
133
|
+
createdAt: baseline.createdAt,
|
|
134
|
+
entries: ratchetedEntries.sort((a, b) => a.ruleId.localeCompare(b.ruleId)),
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
newViolations,
|
|
139
|
+
baselinedViolations,
|
|
140
|
+
resolvedViolations,
|
|
141
|
+
ratchetedBaseline,
|
|
142
|
+
warnings,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
export const POLICY_DIFF_CLASSIFICATIONS = Object.freeze({
|
|
2
|
+
TIGHTEN: "TIGHTEN",
|
|
3
|
+
NEUTRAL: "NEUTRAL",
|
|
4
|
+
WEAKEN: "WEAKEN",
|
|
5
|
+
UNKNOWN: "UNKNOWN",
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
export function diffPolicies(beforePolicy = {}, afterPolicy = {}) {
|
|
9
|
+
const changes = [];
|
|
10
|
+
const beforeRules = new Map((beforePolicy?.rules ?? []).map((r) => [r.id, r]));
|
|
11
|
+
const afterRules = new Map((afterPolicy?.rules ?? []).map((r) => [r.id, r]));
|
|
12
|
+
|
|
13
|
+
// Check removed rules
|
|
14
|
+
for (const [id, beforeRule] of beforeRules.entries()) {
|
|
15
|
+
if (!afterRules.has(id)) {
|
|
16
|
+
const isWeakening = beforeRule.blocking || beforeRule.severity === "HIGH";
|
|
17
|
+
changes.push({
|
|
18
|
+
path: `rules.${id}`,
|
|
19
|
+
type: isWeakening ? "WEAKEN" : "NEUTRAL",
|
|
20
|
+
before: beforeRule,
|
|
21
|
+
after: null,
|
|
22
|
+
description: `Rule ${id} was removed`,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Check added rules
|
|
28
|
+
for (const [id, afterRule] of afterRules.entries()) {
|
|
29
|
+
if (!beforeRules.has(id)) {
|
|
30
|
+
const isTightening = afterRule.blocking || afterRule.severity === "HIGH";
|
|
31
|
+
changes.push({
|
|
32
|
+
path: `rules.${id}`,
|
|
33
|
+
type: isTightening ? "TIGHTEN" : "NEUTRAL",
|
|
34
|
+
before: null,
|
|
35
|
+
after: afterRule,
|
|
36
|
+
description: `Rule ${id} was added`,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Check modified rules
|
|
42
|
+
for (const [id, beforeRule] of beforeRules.entries()) {
|
|
43
|
+
const afterRule = afterRules.get(id);
|
|
44
|
+
if (!afterRule) continue;
|
|
45
|
+
|
|
46
|
+
// Check blocking change
|
|
47
|
+
if (beforeRule.blocking !== afterRule.blocking) {
|
|
48
|
+
const isWeakening = beforeRule.blocking && !afterRule.blocking;
|
|
49
|
+
changes.push({
|
|
50
|
+
path: `rules.${id}.blocking`,
|
|
51
|
+
type: isWeakening ? "WEAKEN" : "TIGHTEN",
|
|
52
|
+
before: beforeRule.blocking,
|
|
53
|
+
after: afterRule.blocking,
|
|
54
|
+
description: isWeakening ? `Rule ${id} was changed from blocking to advisory` : `Rule ${id} was changed to blocking`,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Check threshold change
|
|
59
|
+
const beforeThreshold = beforeRule.check?.threshold ?? beforeRule.parameters?.threshold;
|
|
60
|
+
const afterThreshold = afterRule.check?.threshold ?? afterRule.parameters?.threshold;
|
|
61
|
+
if (beforeThreshold !== undefined && afterThreshold !== undefined && beforeThreshold !== afterThreshold) {
|
|
62
|
+
const isWeakening = afterThreshold > beforeThreshold;
|
|
63
|
+
changes.push({
|
|
64
|
+
path: `rules.${id}.threshold`,
|
|
65
|
+
type: isWeakening ? "WEAKEN" : "TIGHTEN",
|
|
66
|
+
before: beforeThreshold,
|
|
67
|
+
after: afterThreshold,
|
|
68
|
+
description: `Threshold for ${id} changed from ${beforeThreshold} to ${afterThreshold}`,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Check baseline changes
|
|
74
|
+
if (beforePolicy && beforePolicy.baseline === undefined && beforePolicy.baselineDigest) {
|
|
75
|
+
// Legacy snapshot lacking semantic baseline state
|
|
76
|
+
if (afterPolicy?.baselineDigest && beforePolicy.baselineDigest !== afterPolicy.baselineDigest) {
|
|
77
|
+
changes.push({
|
|
78
|
+
path: "baseline",
|
|
79
|
+
type: "UNKNOWN",
|
|
80
|
+
before: beforePolicy.baselineDigest,
|
|
81
|
+
after: afterPolicy.baselineDigest,
|
|
82
|
+
description: "Baseline digest changed but semantic baseline snapshot is unavailable",
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
} else if (beforePolicy?.baseline || afterPolicy?.baseline) {
|
|
86
|
+
const beforeEntries = beforePolicy?.baseline?.entries ?? [];
|
|
87
|
+
const afterEntries = afterPolicy?.baseline?.entries ?? [];
|
|
88
|
+
const beforeBaseline = new Map(beforeEntries.map((e) => [e.ruleId, new Set(e.fingerprints ?? [])]));
|
|
89
|
+
const afterBaseline = new Map(afterEntries.map((e) => [e.ruleId, new Set(e.fingerprints ?? [])]));
|
|
90
|
+
|
|
91
|
+
const allRuleIds = new Set([...beforeBaseline.keys(), ...afterBaseline.keys()]);
|
|
92
|
+
for (const ruleId of allRuleIds) {
|
|
93
|
+
const beforeFpSet = beforeBaseline.get(ruleId) ?? new Set();
|
|
94
|
+
const afterFpSet = afterBaseline.get(ruleId) ?? new Set();
|
|
95
|
+
|
|
96
|
+
const addedFps = [...afterFpSet].filter((fp) => !beforeFpSet.has(fp));
|
|
97
|
+
const removedFps = [...beforeFpSet].filter((fp) => !afterFpSet.has(fp));
|
|
98
|
+
|
|
99
|
+
if (addedFps.length > 0) {
|
|
100
|
+
changes.push({
|
|
101
|
+
path: `baseline.${ruleId}`,
|
|
102
|
+
type: "WEAKEN",
|
|
103
|
+
before: beforeFpSet.size,
|
|
104
|
+
after: afterFpSet.size,
|
|
105
|
+
description: `Added ${addedFps.length} new violations to baseline for rule ${ruleId}`,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
if (removedFps.length > 0) {
|
|
109
|
+
changes.push({
|
|
110
|
+
path: `baseline.${ruleId}`,
|
|
111
|
+
type: "TIGHTEN",
|
|
112
|
+
before: beforeFpSet.size,
|
|
113
|
+
after: afterFpSet.size,
|
|
114
|
+
description: `Resolved and removed ${removedFps.length} baseline violations for rule ${ruleId}`,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let classification = POLICY_DIFF_CLASSIFICATIONS.NEUTRAL;
|
|
121
|
+
if (changes.some((c) => c.type === "WEAKEN")) {
|
|
122
|
+
classification = POLICY_DIFF_CLASSIFICATIONS.WEAKEN;
|
|
123
|
+
} else if (changes.some((c) => c.type === "UNKNOWN")) {
|
|
124
|
+
classification = POLICY_DIFF_CLASSIFICATIONS.UNKNOWN;
|
|
125
|
+
} else if (changes.some((c) => c.type === "TIGHTEN")) {
|
|
126
|
+
classification = POLICY_DIFF_CLASSIFICATIONS.TIGHTEN;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
classification,
|
|
131
|
+
changes,
|
|
132
|
+
};
|
|
133
|
+
}
|