@euconform/core 1.2.0 → 1.3.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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +6 -0
- package/dist/evidence/bundle.d.ts +23 -0
- package/dist/evidence/bundle.d.ts.map +1 -0
- package/dist/evidence/bundle.js +44 -0
- package/dist/evidence/compliance-evaluation.d.ts +12 -0
- package/dist/evidence/compliance-evaluation.d.ts.map +1 -0
- package/dist/evidence/compliance-evaluation.js +78 -0
- package/dist/evidence/hash.d.ts +2 -0
- package/dist/evidence/hash.d.ts.map +1 -0
- package/dist/evidence/hash.js +4 -0
- package/dist/evidence/index.d.ts +13 -0
- package/dist/evidence/index.d.ts.map +1 -0
- package/dist/evidence/index.js +11 -0
- package/dist/evidence/markdown.d.ts +9 -0
- package/dist/evidence/markdown.d.ts.map +1 -0
- package/dist/evidence/markdown.js +169 -0
- package/dist/evidence/output.d.ts +13 -0
- package/dist/evidence/output.d.ts.map +1 -0
- package/dist/evidence/output.js +547 -0
- package/dist/evidence/types.d.ts +230 -0
- package/dist/evidence/types.d.ts.map +1 -0
- package/dist/evidence/types.js +7 -0
- package/dist/evidence/validate.d.ts +8 -0
- package/dist/evidence/validate.d.ts.map +1 -0
- package/dist/evidence/validate.js +175 -0
- package/dist/inference/ollama-client.d.ts +2 -0
- package/dist/inference/ollama-client.d.ts.map +1 -1
- package/dist/inference/ollama-client.js +61 -73
- package/dist/scanner/aggregator.d.ts +7 -0
- package/dist/scanner/aggregator.d.ts.map +1 -0
- package/dist/scanner/aggregator.js +200 -0
- package/dist/scanner/detectors/ai-framework-detector.d.ts +3 -0
- package/dist/scanner/detectors/ai-framework-detector.d.ts.map +1 -0
- package/dist/scanner/detectors/ai-framework-detector.js +74 -0
- package/dist/scanner/detectors/ai-provider-detector.d.ts +3 -0
- package/dist/scanner/detectors/ai-provider-detector.d.ts.map +1 -0
- package/dist/scanner/detectors/ai-provider-detector.js +100 -0
- package/dist/scanner/detectors/compliance-detector.d.ts +3 -0
- package/dist/scanner/detectors/compliance-detector.d.ts.map +1 -0
- package/dist/scanner/detectors/compliance-detector.js +201 -0
- package/dist/scanner/detectors/config-detector.d.ts +3 -0
- package/dist/scanner/detectors/config-detector.d.ts.map +1 -0
- package/dist/scanner/detectors/config-detector.js +248 -0
- package/dist/scanner/detectors/framework-detector.d.ts +3 -0
- package/dist/scanner/detectors/framework-detector.d.ts.map +1 -0
- package/dist/scanner/detectors/framework-detector.js +141 -0
- package/dist/scanner/detectors/index.d.ts +8 -0
- package/dist/scanner/detectors/index.d.ts.map +1 -0
- package/dist/scanner/detectors/index.js +37 -0
- package/dist/scanner/detectors/local-inference-detector.d.ts +3 -0
- package/dist/scanner/detectors/local-inference-detector.d.ts.map +1 -0
- package/dist/scanner/detectors/local-inference-detector.js +93 -0
- package/dist/scanner/detectors/markdown-detector.d.ts +3 -0
- package/dist/scanner/detectors/markdown-detector.d.ts.map +1 -0
- package/dist/scanner/detectors/markdown-detector.js +139 -0
- package/dist/scanner/detectors/package-json-detector.d.ts +3 -0
- package/dist/scanner/detectors/package-json-detector.d.ts.map +1 -0
- package/dist/scanner/detectors/package-json-detector.js +166 -0
- package/dist/scanner/detectors/rag-detector.d.ts +3 -0
- package/dist/scanner/detectors/rag-detector.d.ts.map +1 -0
- package/dist/scanner/detectors/rag-detector.js +132 -0
- package/dist/scanner/detectors/shared.d.ts +18 -0
- package/dist/scanner/detectors/shared.d.ts.map +1 -0
- package/dist/scanner/detectors/shared.js +36 -0
- package/dist/scanner/discovery.d.ts +14 -0
- package/dist/scanner/discovery.d.ts.map +1 -0
- package/dist/scanner/discovery.js +235 -0
- package/dist/scanner/index.d.ts +9 -0
- package/dist/scanner/index.d.ts.map +1 -0
- package/dist/scanner/index.js +8 -0
- package/package.json +6 -3
- package/src/evidence/bundle.ts +65 -0
- package/src/evidence/compliance-evaluation.ts +114 -0
- package/src/evidence/hash.ts +5 -0
- package/src/evidence/index.ts +51 -0
- package/src/evidence/markdown.ts +226 -0
- package/src/evidence/output.ts +717 -0
- package/src/evidence/types.ts +329 -0
- package/src/evidence/validate.ts +270 -0
- package/src/inference/ollama-client.ts +81 -90
- package/src/scanner/aggregator.ts +258 -0
- package/src/scanner/detectors/ai-framework-detector.ts +94 -0
- package/src/scanner/detectors/ai-provider-detector.ts +135 -0
- package/src/scanner/detectors/compliance-detector.ts +232 -0
- package/src/scanner/detectors/config-detector.ts +316 -0
- package/src/scanner/detectors/framework-detector.ts +163 -0
- package/src/scanner/detectors/index.ts +40 -0
- package/src/scanner/detectors/local-inference-detector.ts +124 -0
- package/src/scanner/detectors/markdown-detector.ts +176 -0
- package/src/scanner/detectors/package-json-detector.ts +202 -0
- package/src/scanner/detectors/rag-detector.ts +162 -0
- package/src/scanner/detectors/shared.ts +45 -0
- package/src/scanner/discovery.ts +290 -0
- package/src/scanner/index.ts +9 -0
- package/tests/evidence/bundle.test.ts +128 -0
- package/tests/evidence/ecef-schemas.test.ts +316 -0
- package/tests/evidence/hash.test.ts +25 -0
- package/tests/evidence/markdown.test.ts +92 -0
- package/tests/evidence/output.test.ts +203 -0
- package/tests/fixtures/compliance-good/README.md +14 -0
- package/tests/fixtures/compliance-good/lib/ai-service.ts +17 -0
- package/tests/fixtures/compliance-good/lib/review-flow.ts +11 -0
- package/tests/fixtures/compliance-good/package.json +10 -0
- package/tests/fixtures/compliance-good/tests/bias-check.test.ts +13 -0
- package/tests/fixtures/nextjs-openai/README.md +5 -0
- package/tests/fixtures/nextjs-openai/app/page.tsx +11 -0
- package/tests/fixtures/nextjs-openai/package.json +9 -0
- package/tests/fixtures/ollama-rag/.env.example +2 -0
- package/tests/fixtures/ollama-rag/docker-compose.yml +9 -0
- package/tests/fixtures/ollama-rag/package.json +9 -0
- package/tests/fixtures/ollama-rag/server.ts +21 -0
- package/tests/fixtures/plain-webapp/README.md +3 -0
- package/tests/fixtures/plain-webapp/index.js +5 -0
- package/tests/fixtures/plain-webapp/package.json +8 -0
- package/tests/gpai-classifier.test.ts +4 -4
- package/tests/gpai-timeline.test.ts +2 -2
- package/tests/scanner/aggregator.test.ts +122 -0
- package/tests/scanner/detectors.test.ts +274 -0
- package/tests/scanner/discovery.test.ts +87 -0
- package/vitest.config.ts +1 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
type JsonSchema = {
|
|
6
|
+
$defs?: Record<string, JsonSchema>;
|
|
7
|
+
$ref?: string;
|
|
8
|
+
const?: unknown;
|
|
9
|
+
enum?: unknown[];
|
|
10
|
+
type?: "object" | "array" | "string" | "boolean" | "integer";
|
|
11
|
+
required?: string[];
|
|
12
|
+
properties?: Record<string, JsonSchema>;
|
|
13
|
+
additionalProperties?: boolean;
|
|
14
|
+
items?: JsonSchema;
|
|
15
|
+
format?: string;
|
|
16
|
+
pattern?: string;
|
|
17
|
+
minItems?: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const ROOT = resolve(__dirname, "../../../..");
|
|
21
|
+
const SCHEMAS_DIR = resolve(ROOT, "docs/ecef/schemas");
|
|
22
|
+
const EXAMPLES_DIR = resolve(ROOT, "docs/ecef/examples");
|
|
23
|
+
|
|
24
|
+
function readJson<T>(path: string): T {
|
|
25
|
+
return JSON.parse(readFileSync(path, "utf8")) as T;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function resolveRef(rootSchema: JsonSchema, ref: string): JsonSchema {
|
|
29
|
+
if (!ref.startsWith("#/")) {
|
|
30
|
+
throw new Error(`Unsupported schema ref: ${ref}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const pathSegments = ref.slice(2).split("/");
|
|
34
|
+
let current: unknown = rootSchema;
|
|
35
|
+
|
|
36
|
+
for (const segment of pathSegments) {
|
|
37
|
+
if (!current || typeof current !== "object" || !(segment in current)) {
|
|
38
|
+
throw new Error(`Unable to resolve schema ref: ${ref}`);
|
|
39
|
+
}
|
|
40
|
+
current = (current as Record<string, unknown>)[segment];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return current as JsonSchema;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function checkAdditionalProperties(
|
|
47
|
+
schema: JsonSchema,
|
|
48
|
+
objectValue: Record<string, unknown>,
|
|
49
|
+
path: string
|
|
50
|
+
): string[] {
|
|
51
|
+
if (schema.additionalProperties !== false || !schema.properties) return [];
|
|
52
|
+
const allowed = new Set(Object.keys(schema.properties));
|
|
53
|
+
return Object.keys(objectValue)
|
|
54
|
+
.filter((key) => !allowed.has(key))
|
|
55
|
+
.map((key) => `${path}.${key} is not allowed (additionalProperties: false)`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function validateObjectSchema(
|
|
59
|
+
rootSchema: JsonSchema,
|
|
60
|
+
schema: JsonSchema,
|
|
61
|
+
value: unknown,
|
|
62
|
+
path: string
|
|
63
|
+
): string[] {
|
|
64
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
65
|
+
return [`${path} must be an object`];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const errors: string[] = [];
|
|
69
|
+
const objectValue = value as Record<string, unknown>;
|
|
70
|
+
|
|
71
|
+
for (const field of schema.required ?? []) {
|
|
72
|
+
if (!(field in objectValue)) {
|
|
73
|
+
errors.push(`${path}.${field} is required`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
errors.push(...checkAdditionalProperties(schema, objectValue, path));
|
|
78
|
+
|
|
79
|
+
for (const [key, propertySchema] of Object.entries(schema.properties ?? {})) {
|
|
80
|
+
if (key in objectValue) {
|
|
81
|
+
errors.push(
|
|
82
|
+
...validateAgainstSchema(rootSchema, propertySchema, objectValue[key], `${path}.${key}`)
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return errors;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function validateArraySchema(
|
|
91
|
+
rootSchema: JsonSchema,
|
|
92
|
+
schema: JsonSchema,
|
|
93
|
+
value: unknown,
|
|
94
|
+
path: string
|
|
95
|
+
): string[] {
|
|
96
|
+
if (!Array.isArray(value)) {
|
|
97
|
+
return [`${path} must be an array`];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const errors: string[] = [];
|
|
101
|
+
|
|
102
|
+
if (schema.minItems !== undefined && value.length < schema.minItems) {
|
|
103
|
+
errors.push(`${path} must have at least ${schema.minItems} item(s)`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!schema.items) return errors;
|
|
107
|
+
|
|
108
|
+
for (let i = 0; i < value.length; i++) {
|
|
109
|
+
errors.push(...validateAgainstSchema(rootSchema, schema.items, value[i], `${path}[${i}]`));
|
|
110
|
+
}
|
|
111
|
+
return errors;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function validatePrimitive(schema: JsonSchema, value: unknown, path: string): string[] {
|
|
115
|
+
if (schema.type === "string") {
|
|
116
|
+
if (typeof value !== "string") return [`${path} must be a string`];
|
|
117
|
+
if (schema.pattern && !new RegExp(schema.pattern).test(value)) {
|
|
118
|
+
return [`${path} must match pattern ${schema.pattern}`];
|
|
119
|
+
}
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
if (schema.type === "boolean" && typeof value !== "boolean") {
|
|
123
|
+
return [`${path} must be a boolean`];
|
|
124
|
+
}
|
|
125
|
+
if (schema.type === "integer" && !Number.isInteger(value)) {
|
|
126
|
+
return [`${path} must be an integer`];
|
|
127
|
+
}
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function validateAgainstSchema(
|
|
132
|
+
rootSchema: JsonSchema,
|
|
133
|
+
schema: JsonSchema,
|
|
134
|
+
value: unknown,
|
|
135
|
+
path = "$"
|
|
136
|
+
): string[] {
|
|
137
|
+
if (schema.$ref) {
|
|
138
|
+
return validateAgainstSchema(rootSchema, resolveRef(rootSchema, schema.$ref), value, path);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if ("const" in schema && value !== schema.const) {
|
|
142
|
+
return [`${path} must equal ${JSON.stringify(schema.const)}`];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (schema.enum && !schema.enum.includes(value)) {
|
|
146
|
+
return [
|
|
147
|
+
`${path} must be one of ${schema.enum.map((entry) => JSON.stringify(entry)).join(", ")}`,
|
|
148
|
+
];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (schema.type === "object") return validateObjectSchema(rootSchema, schema, value, path);
|
|
152
|
+
if (schema.type === "array") return validateArraySchema(rootSchema, schema, value, path);
|
|
153
|
+
|
|
154
|
+
return validatePrimitive(schema, value, path);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const SCHEMA_FILES = {
|
|
158
|
+
report: resolve(SCHEMAS_DIR, "report-v1.schema.json"),
|
|
159
|
+
aibom: resolve(SCHEMAS_DIR, "aibom-v1.schema.json"),
|
|
160
|
+
ci: resolve(SCHEMAS_DIR, "ci-v1.schema.json"),
|
|
161
|
+
bundle: resolve(SCHEMAS_DIR, "bundle-v1.schema.json"),
|
|
162
|
+
} as const;
|
|
163
|
+
|
|
164
|
+
const EXAMPLE_FILES = {
|
|
165
|
+
report: "euconform.report.json",
|
|
166
|
+
aibom: "euconform.aibom.json",
|
|
167
|
+
ci: "euconform.ci.json",
|
|
168
|
+
bundle: "euconform.bundle.json",
|
|
169
|
+
} as const;
|
|
170
|
+
|
|
171
|
+
describe("ECEF schemas", () => {
|
|
172
|
+
const schemaEntries = Object.entries(SCHEMA_FILES).map(([kind, path]) => [
|
|
173
|
+
kind,
|
|
174
|
+
readJson<JsonSchema>(path),
|
|
175
|
+
]) as Array<[keyof typeof SCHEMA_FILES, JsonSchema]>;
|
|
176
|
+
|
|
177
|
+
it("validates every documented example artifact set", () => {
|
|
178
|
+
const exampleDirs = readdirSync(EXAMPLES_DIR, { withFileTypes: true })
|
|
179
|
+
.filter((entry) => entry.isDirectory())
|
|
180
|
+
.map((entry) => entry.name)
|
|
181
|
+
.sort();
|
|
182
|
+
|
|
183
|
+
expect(exampleDirs).toEqual(["local-ollama", "non-ai", "rag", "web-app"]);
|
|
184
|
+
|
|
185
|
+
for (const exampleDir of exampleDirs) {
|
|
186
|
+
for (const [kind, schema] of schemaEntries) {
|
|
187
|
+
const artifactPath = resolve(EXAMPLES_DIR, exampleDir, EXAMPLE_FILES[kind]);
|
|
188
|
+
const artifact = readJson<unknown>(artifactPath);
|
|
189
|
+
const errors = validateAgainstSchema(schema, schema, artifact);
|
|
190
|
+
|
|
191
|
+
expect(errors, `${exampleDir}/${EXAMPLE_FILES[kind]}\n${errors.join("\n")}`).toEqual([]);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("rejects documents with missing required fields", () => {
|
|
197
|
+
for (const [kind, schema] of schemaEntries) {
|
|
198
|
+
const minimal: Record<string, unknown> = {};
|
|
199
|
+
const errors = validateAgainstSchema(schema, schema, minimal);
|
|
200
|
+
expect(errors.length, `${kind}: empty object should fail validation`).toBeGreaterThan(0);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("rejects documents with wrong types", () => {
|
|
205
|
+
const reportSchema = schemaEntries.find(([kind]) => kind === "report")?.[1];
|
|
206
|
+
if (!reportSchema) throw new Error("report schema not found");
|
|
207
|
+
|
|
208
|
+
const badReport = {
|
|
209
|
+
schemaVersion: "euconform.report.v1",
|
|
210
|
+
generatedAt: 12345,
|
|
211
|
+
tool: "not-an-object",
|
|
212
|
+
target: null,
|
|
213
|
+
aiFootprint: [],
|
|
214
|
+
complianceSignals: "wrong",
|
|
215
|
+
assessmentHints: false,
|
|
216
|
+
gaps: "not-array",
|
|
217
|
+
recommendationSummary: {},
|
|
218
|
+
};
|
|
219
|
+
const errors = validateAgainstSchema(reportSchema, reportSchema, badReport);
|
|
220
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("rejects documents with additional properties", () => {
|
|
224
|
+
const artifactPath = resolve(EXAMPLES_DIR, "web-app", EXAMPLE_FILES.report);
|
|
225
|
+
const artifact = readJson<Record<string, unknown>>(artifactPath);
|
|
226
|
+
const extraProps = { ...artifact, unknownField: "should-fail" };
|
|
227
|
+
|
|
228
|
+
const reportSchema = schemaEntries.find(([kind]) => kind === "report")?.[1];
|
|
229
|
+
if (!reportSchema) throw new Error("report schema not found");
|
|
230
|
+
|
|
231
|
+
const errors = validateAgainstSchema(reportSchema, reportSchema, extraProps);
|
|
232
|
+
expect(errors.some((e) => e.includes("unknownField"))).toBe(true);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("rejects invalid enum values", () => {
|
|
236
|
+
const ciSchema = schemaEntries.find(([kind]) => kind === "ci")?.[1];
|
|
237
|
+
if (!ciSchema) throw new Error("ci schema not found");
|
|
238
|
+
|
|
239
|
+
const artifactPath = resolve(EXAMPLES_DIR, "web-app", EXAMPLE_FILES.ci);
|
|
240
|
+
const artifact = readJson<Record<string, unknown>>(artifactPath);
|
|
241
|
+
const badCi = {
|
|
242
|
+
...artifact,
|
|
243
|
+
scanScope: "invalid-scope",
|
|
244
|
+
};
|
|
245
|
+
const errors = validateAgainstSchema(ciSchema, ciSchema, badCi);
|
|
246
|
+
expect(errors.some((e) => e.includes("scanScope"))).toBe(true);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("keeps schemaVersion aligned with the documented file family", () => {
|
|
250
|
+
const expectedVersions = {
|
|
251
|
+
report: "euconform.report.v1",
|
|
252
|
+
aibom: "euconform.aibom.v1",
|
|
253
|
+
ci: "euconform.ci.v1",
|
|
254
|
+
bundle: "euconform.bundle.v1",
|
|
255
|
+
} as const;
|
|
256
|
+
|
|
257
|
+
const exampleDirs = readdirSync(EXAMPLES_DIR, { withFileTypes: true })
|
|
258
|
+
.filter((entry) => entry.isDirectory())
|
|
259
|
+
.map((entry) => entry.name);
|
|
260
|
+
|
|
261
|
+
for (const exampleDir of exampleDirs) {
|
|
262
|
+
for (const [kind, filename] of Object.entries(EXAMPLE_FILES) as Array<
|
|
263
|
+
[keyof typeof EXAMPLE_FILES, string]
|
|
264
|
+
>) {
|
|
265
|
+
const artifact = readJson<{ schemaVersion?: string }>(
|
|
266
|
+
resolve(EXAMPLES_DIR, exampleDir, filename)
|
|
267
|
+
);
|
|
268
|
+
expect(artifact.schemaVersion).toBe(expectedVersions[kind]);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("rejects bundle with invalid sha256 format", () => {
|
|
274
|
+
const bundleSchema = schemaEntries.find(([kind]) => kind === "bundle")?.[1];
|
|
275
|
+
if (!bundleSchema) throw new Error("bundle schema not found");
|
|
276
|
+
|
|
277
|
+
const badBundle = {
|
|
278
|
+
schemaVersion: "euconform.bundle.v1",
|
|
279
|
+
generatedAt: "2026-01-01T00:00:00Z",
|
|
280
|
+
tool: { name: "euconform", version: "1.0.0" },
|
|
281
|
+
target: { name: "test", rootPath: "/test" },
|
|
282
|
+
artifacts: [
|
|
283
|
+
{
|
|
284
|
+
role: "report",
|
|
285
|
+
fileName: "euconform.report.json",
|
|
286
|
+
sha256: "not-a-valid-hash",
|
|
287
|
+
required: true,
|
|
288
|
+
},
|
|
289
|
+
],
|
|
290
|
+
};
|
|
291
|
+
const errors = validateAgainstSchema(bundleSchema, bundleSchema, badBundle);
|
|
292
|
+
expect(errors.some((e) => e.includes("sha256"))).toBe(true);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("rejects bundle with invalid artifact role", () => {
|
|
296
|
+
const bundleSchema = schemaEntries.find(([kind]) => kind === "bundle")?.[1];
|
|
297
|
+
if (!bundleSchema) throw new Error("bundle schema not found");
|
|
298
|
+
|
|
299
|
+
const badBundle = {
|
|
300
|
+
schemaVersion: "euconform.bundle.v1",
|
|
301
|
+
generatedAt: "2026-01-01T00:00:00Z",
|
|
302
|
+
tool: { name: "euconform", version: "1.0.0" },
|
|
303
|
+
target: { name: "test", rootPath: "/test" },
|
|
304
|
+
artifacts: [
|
|
305
|
+
{
|
|
306
|
+
role: "unknown-role",
|
|
307
|
+
fileName: "test.json",
|
|
308
|
+
sha256: "a".repeat(64),
|
|
309
|
+
required: true,
|
|
310
|
+
},
|
|
311
|
+
],
|
|
312
|
+
};
|
|
313
|
+
const errors = validateAgainstSchema(bundleSchema, bundleSchema, badBundle);
|
|
314
|
+
expect(errors.some((e) => e.includes("role"))).toBe(true);
|
|
315
|
+
});
|
|
316
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { sha256Hex } from "../../src/evidence/hash";
|
|
3
|
+
|
|
4
|
+
describe("sha256Hex", () => {
|
|
5
|
+
it("produces correct hex digest for known input", () => {
|
|
6
|
+
expect(sha256Hex("hello")).toBe(
|
|
7
|
+
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
|
|
8
|
+
);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("produces 64-character hex string", () => {
|
|
12
|
+
const result = sha256Hex("test content");
|
|
13
|
+
expect(result).toMatch(/^[a-f0-9]{64}$/);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("handles empty string", () => {
|
|
17
|
+
const result = sha256Hex("");
|
|
18
|
+
expect(result).toMatch(/^[a-f0-9]{64}$/);
|
|
19
|
+
expect(result).toBe("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("produces different hashes for different inputs", () => {
|
|
23
|
+
expect(sha256Hex("a")).not.toBe(sha256Hex("b"));
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { generateScanOutput } from "../../src/evidence/output";
|
|
4
|
+
import { scanRepository } from "../../src/scanner/aggregator";
|
|
5
|
+
|
|
6
|
+
const FIXTURES = resolve(import.meta.dirname, "../fixtures");
|
|
7
|
+
|
|
8
|
+
describe("generateSummaryMarkdown", () => {
|
|
9
|
+
it("includes disclaimer text", async () => {
|
|
10
|
+
const scanResult = await scanRepository({
|
|
11
|
+
targetPath: resolve(FIXTURES, "nextjs-openai"),
|
|
12
|
+
});
|
|
13
|
+
const output = generateScanOutput(scanResult);
|
|
14
|
+
|
|
15
|
+
expect(output.summaryMarkdown).toContain("technical guidance only");
|
|
16
|
+
expect(output.summaryMarkdown).toContain("does not constitute legal advice");
|
|
17
|
+
expect(output.summaryMarkdown).toContain("Scan scope: `production`");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("includes project overview section", async () => {
|
|
21
|
+
const scanResult = await scanRepository({
|
|
22
|
+
targetPath: resolve(FIXTURES, "nextjs-openai"),
|
|
23
|
+
});
|
|
24
|
+
const output = generateScanOutput(scanResult);
|
|
25
|
+
|
|
26
|
+
expect(output.summaryMarkdown).toContain("## Project Overview");
|
|
27
|
+
expect(output.summaryMarkdown).toContain(output.report.target.name);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("includes AI components section", async () => {
|
|
31
|
+
const scanResult = await scanRepository({
|
|
32
|
+
targetPath: resolve(FIXTURES, "nextjs-openai"),
|
|
33
|
+
});
|
|
34
|
+
const output = generateScanOutput(scanResult);
|
|
35
|
+
|
|
36
|
+
expect(output.summaryMarkdown).toContain("## AI Components Detected");
|
|
37
|
+
// Table headers
|
|
38
|
+
expect(output.summaryMarkdown).toContain("| Component |");
|
|
39
|
+
expect(output.summaryMarkdown).toContain("| Kind |");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("includes compliance signals table", async () => {
|
|
43
|
+
const scanResult = await scanRepository({
|
|
44
|
+
targetPath: resolve(FIXTURES, "nextjs-openai"),
|
|
45
|
+
});
|
|
46
|
+
const output = generateScanOutput(scanResult);
|
|
47
|
+
|
|
48
|
+
expect(output.summaryMarkdown).toContain("## Compliance Signals");
|
|
49
|
+
expect(output.summaryMarkdown).toContain("| Area | Status |");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("includes open questions section", async () => {
|
|
53
|
+
const scanResult = await scanRepository({
|
|
54
|
+
targetPath: resolve(FIXTURES, "nextjs-openai"),
|
|
55
|
+
});
|
|
56
|
+
const output = generateScanOutput(scanResult);
|
|
57
|
+
|
|
58
|
+
expect(output.summaryMarkdown).toContain("## Open Questions");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("includes recommended actions section", async () => {
|
|
62
|
+
const scanResult = await scanRepository({
|
|
63
|
+
targetPath: resolve(FIXTURES, "nextjs-openai"),
|
|
64
|
+
});
|
|
65
|
+
const output = generateScanOutput(scanResult);
|
|
66
|
+
|
|
67
|
+
expect(output.summaryMarkdown).toContain("## Recommended Actions");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("includes footer with artifact references", async () => {
|
|
71
|
+
const scanResult = await scanRepository({
|
|
72
|
+
targetPath: resolve(FIXTURES, "nextjs-openai"),
|
|
73
|
+
});
|
|
74
|
+
const output = generateScanOutput(scanResult);
|
|
75
|
+
|
|
76
|
+
expect(output.summaryMarkdown).toContain("euconform.report.json");
|
|
77
|
+
expect(output.summaryMarkdown).toContain("euconform.aibom.json");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("handles project with no AI components", async () => {
|
|
81
|
+
const scanResult = await scanRepository({
|
|
82
|
+
targetPath: resolve(FIXTURES, "plain-webapp"),
|
|
83
|
+
});
|
|
84
|
+
const output = generateScanOutput(scanResult);
|
|
85
|
+
|
|
86
|
+
// plain-webapp still lists framework components (e.g. express) in the BOM,
|
|
87
|
+
// but the report should indicate no AI is being used
|
|
88
|
+
expect(output.report.aiFootprint.usesAI).toBe(false);
|
|
89
|
+
// Assessment hints section should reflect no AI detected
|
|
90
|
+
expect(output.summaryMarkdown).toContain("No assessment hints generated (no AI detected)");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { generateScanOutput } from "../../src/evidence/output";
|
|
4
|
+
import { scanRepository } from "../../src/scanner/aggregator";
|
|
5
|
+
|
|
6
|
+
const FIXTURES = resolve(__dirname, "../fixtures");
|
|
7
|
+
const MONOREPO_ROOT = resolve(__dirname, "../../../..");
|
|
8
|
+
|
|
9
|
+
describe("generateScanOutput", () => {
|
|
10
|
+
it("generates valid report schema for AI project", async () => {
|
|
11
|
+
const scanResult = await scanRepository({
|
|
12
|
+
targetPath: resolve(FIXTURES, "nextjs-openai"),
|
|
13
|
+
});
|
|
14
|
+
const output = generateScanOutput(scanResult);
|
|
15
|
+
|
|
16
|
+
expect(output.report.schemaVersion).toBe("euconform.report.v1");
|
|
17
|
+
expect(output.report.generatedAt).toBeTruthy();
|
|
18
|
+
expect(output.report.tool.name).toBe("euconform");
|
|
19
|
+
expect(output.report.tool.version).toBeTruthy();
|
|
20
|
+
expect(output.report.target.rootPath).toBeTruthy();
|
|
21
|
+
expect(output.report.target.name).toBeTruthy();
|
|
22
|
+
expect(output.report.aiFootprint).toBeDefined();
|
|
23
|
+
expect(output.report.complianceSignals).toBeDefined();
|
|
24
|
+
expect(output.report.assessmentHints).toBeDefined();
|
|
25
|
+
expect(output.report.gaps).toBeDefined();
|
|
26
|
+
expect(output.report.recommendationSummary).toBeDefined();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("keeps documentation-only disclosure as a hint, not an implementation", async () => {
|
|
30
|
+
const scanResult = await scanRepository({
|
|
31
|
+
targetPath: resolve(FIXTURES, "nextjs-openai"),
|
|
32
|
+
});
|
|
33
|
+
const output = generateScanOutput(scanResult);
|
|
34
|
+
|
|
35
|
+
expect(output.report.complianceSignals.disclosure.status).toBe("absent");
|
|
36
|
+
expect(
|
|
37
|
+
output.report.assessmentHints.riskIndicators.some((hint) =>
|
|
38
|
+
hint.hint.toLowerCase().includes("documentation references ai disclosure")
|
|
39
|
+
)
|
|
40
|
+
).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("generates valid AIBOM schema", async () => {
|
|
44
|
+
const scanResult = await scanRepository({
|
|
45
|
+
targetPath: resolve(FIXTURES, "nextjs-openai"),
|
|
46
|
+
});
|
|
47
|
+
const output = generateScanOutput(scanResult);
|
|
48
|
+
|
|
49
|
+
expect(output.aibom.schemaVersion).toBe("euconform.aibom.v1");
|
|
50
|
+
expect(output.aibom.project.name).toBeTruthy();
|
|
51
|
+
expect(Array.isArray(output.aibom.components)).toBe(true);
|
|
52
|
+
expect(output.aibom.components.length).toBeGreaterThan(0);
|
|
53
|
+
|
|
54
|
+
// Each component should have required fields
|
|
55
|
+
for (const comp of output.aibom.components) {
|
|
56
|
+
expect(comp.id).toBeTruthy();
|
|
57
|
+
expect(comp.kind).toBeTruthy();
|
|
58
|
+
expect(comp.name).toBeTruthy();
|
|
59
|
+
expect(comp.source).toBeTruthy();
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("generates stable, readable AIBOM component ids", { timeout: 15_000 }, async () => {
|
|
64
|
+
const scanResult = await scanRepository({
|
|
65
|
+
targetPath: MONOREPO_ROOT,
|
|
66
|
+
});
|
|
67
|
+
const output = generateScanOutput(scanResult);
|
|
68
|
+
const ids = output.aibom.components.map((component) => component.id);
|
|
69
|
+
|
|
70
|
+
expect(new Set(ids).size).toBe(ids.length);
|
|
71
|
+
expect(ids.every((id) => !id.includes("--"))).toBe(true);
|
|
72
|
+
expect(ids.every((id) => id.includes(":"))).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("derives export capabilities from reporting signals", { timeout: 15_000 }, async () => {
|
|
76
|
+
const scanResult = await scanRepository({
|
|
77
|
+
targetPath: MONOREPO_ROOT,
|
|
78
|
+
});
|
|
79
|
+
const output = generateScanOutput(scanResult);
|
|
80
|
+
|
|
81
|
+
const reportingEvidence = output.report.complianceSignals.reportingExports.evidence;
|
|
82
|
+
expect(output.aibom.complianceCapabilities.jsonExport).toBe(
|
|
83
|
+
reportingEvidence.some(
|
|
84
|
+
(e) => e.file.includes(".json") || e.snippet.toLowerCase().includes("json")
|
|
85
|
+
)
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("generates summary markdown", async () => {
|
|
90
|
+
const scanResult = await scanRepository({
|
|
91
|
+
targetPath: resolve(FIXTURES, "nextjs-openai"),
|
|
92
|
+
});
|
|
93
|
+
const output = generateScanOutput(scanResult);
|
|
94
|
+
|
|
95
|
+
expect(typeof output.summaryMarkdown).toBe("string");
|
|
96
|
+
expect(output.summaryMarkdown.length).toBeGreaterThan(0);
|
|
97
|
+
|
|
98
|
+
// Should contain expected markdown sections
|
|
99
|
+
expect(output.summaryMarkdown).toContain("# EuConform Scan Report");
|
|
100
|
+
expect(output.summaryMarkdown).toContain("## Project Overview");
|
|
101
|
+
expect(output.summaryMarkdown).toContain("## AI Components Detected");
|
|
102
|
+
expect(output.summaryMarkdown).toContain("## Compliance Signals");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("reports AI detected = true for AI project", async () => {
|
|
106
|
+
const scanResult = await scanRepository({
|
|
107
|
+
targetPath: resolve(FIXTURES, "nextjs-openai"),
|
|
108
|
+
});
|
|
109
|
+
const output = generateScanOutput(scanResult);
|
|
110
|
+
|
|
111
|
+
expect(output.report.aiFootprint.usesAI).toBe(true);
|
|
112
|
+
expect(output.report.aiFootprint.providerHints.length).toBeGreaterThan(0);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("reports AI detected = false for plain webapp", async () => {
|
|
116
|
+
const scanResult = await scanRepository({
|
|
117
|
+
targetPath: resolve(FIXTURES, "plain-webapp"),
|
|
118
|
+
});
|
|
119
|
+
const output = generateScanOutput(scanResult);
|
|
120
|
+
|
|
121
|
+
expect(output.report.aiFootprint.usesAI).toBe(false);
|
|
122
|
+
expect(output.report.aiFootprint.providerHints).toHaveLength(0);
|
|
123
|
+
expect(output.report.aiFootprint.inferenceModes).toHaveLength(0);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("generates gaps for AI project without compliance signals", async () => {
|
|
127
|
+
const scanResult = await scanRepository({
|
|
128
|
+
targetPath: resolve(FIXTURES, "nextjs-openai"),
|
|
129
|
+
});
|
|
130
|
+
const output = generateScanOutput(scanResult);
|
|
131
|
+
|
|
132
|
+
// nextjs-openai has AI but minimal compliance infrastructure,
|
|
133
|
+
// so gaps should be generated
|
|
134
|
+
expect(output.report.gaps.length).toBeGreaterThan(0);
|
|
135
|
+
|
|
136
|
+
// Each gap should have required fields
|
|
137
|
+
for (const gap of output.report.gaps) {
|
|
138
|
+
expect(gap.id).toBeTruthy();
|
|
139
|
+
expect(gap.title).toBeTruthy();
|
|
140
|
+
expect(gap.description).toBeTruthy();
|
|
141
|
+
expect(["critical", "high", "medium", "low"]).toContain(gap.priority);
|
|
142
|
+
expect(["missing", "partial"]).toContain(gap.status);
|
|
143
|
+
expect(gap.basis).toBe("scanner-rule");
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("generates no gaps for plain webapp", async () => {
|
|
148
|
+
const scanResult = await scanRepository({
|
|
149
|
+
targetPath: resolve(FIXTURES, "plain-webapp"),
|
|
150
|
+
});
|
|
151
|
+
const output = generateScanOutput(scanResult);
|
|
152
|
+
|
|
153
|
+
// No AI detected means no compliance gaps
|
|
154
|
+
expect(output.report.gaps).toHaveLength(0);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("includes compliance signals in report", async () => {
|
|
158
|
+
const scanResult = await scanRepository({
|
|
159
|
+
targetPath: resolve(FIXTURES, "compliance-good"),
|
|
160
|
+
});
|
|
161
|
+
const output = generateScanOutput(scanResult);
|
|
162
|
+
|
|
163
|
+
const { complianceSignals } = output.report;
|
|
164
|
+
|
|
165
|
+
// compliance-good has logging (pino) and incident reporting (sentry)
|
|
166
|
+
// At least some compliance areas should not be "absent"
|
|
167
|
+
const statuses = Object.values(complianceSignals).map((g) => g.status);
|
|
168
|
+
const nonAbsent = statuses.filter((s) => s === "present" || s === "partial");
|
|
169
|
+
expect(nonAbsent.length).toBeGreaterThan(0);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("includes recommendations", async () => {
|
|
173
|
+
const scanResult = await scanRepository({
|
|
174
|
+
targetPath: resolve(FIXTURES, "nextjs-openai"),
|
|
175
|
+
});
|
|
176
|
+
const output = generateScanOutput(scanResult);
|
|
177
|
+
|
|
178
|
+
expect(output.report.recommendationSummary.length).toBeGreaterThan(0);
|
|
179
|
+
|
|
180
|
+
// Should contain wizard recommendation or be capped at 5 entries
|
|
181
|
+
const hasWizard = output.report.recommendationSummary.some((r) =>
|
|
182
|
+
r.toLowerCase().includes("wizard")
|
|
183
|
+
);
|
|
184
|
+
// The list is capped at 5 — wizard may be trimmed for projects with many gaps
|
|
185
|
+
if (output.report.recommendationSummary.length < 5) {
|
|
186
|
+
expect(hasWizard).toBe(true);
|
|
187
|
+
}
|
|
188
|
+
expect(output.report.recommendationSummary.length).toBeLessThanOrEqual(5);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("uses softened GPAI wording for cloud providers", async () => {
|
|
192
|
+
const scanResult = await scanRepository({
|
|
193
|
+
targetPath: resolve(FIXTURES, "nextjs-openai"),
|
|
194
|
+
});
|
|
195
|
+
const output = generateScanOutput(scanResult);
|
|
196
|
+
|
|
197
|
+
expect(
|
|
198
|
+
output.report.assessmentHints.gpaiIndicators.some((hint) =>
|
|
199
|
+
hint.hint.includes("Provider obligations depend on your role")
|
|
200
|
+
)
|
|
201
|
+
).toBe(true);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Compliant AI Service
|
|
2
|
+
|
|
3
|
+
This service integrates AI capabilities with full compliance infrastructure.
|
|
4
|
+
|
|
5
|
+
## AI Disclosure
|
|
6
|
+
|
|
7
|
+
This service uses artificial intelligence (OpenAI GPT-4) to generate responses.
|
|
8
|
+
All AI-generated outputs are clearly labeled and subject to human review.
|
|
9
|
+
|
|
10
|
+
## Data Governance
|
|
11
|
+
|
|
12
|
+
- All user data is anonymized before processing
|
|
13
|
+
- AI model inputs and outputs are logged for audit purposes
|
|
14
|
+
- Data retention policy: 90 days
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import OpenAI from "openai";
|
|
2
|
+
import pino from "pino";
|
|
3
|
+
|
|
4
|
+
const logger = pino({ name: "ai-service" });
|
|
5
|
+
const openai = new OpenAI();
|
|
6
|
+
|
|
7
|
+
export async function generateResponse(prompt: string, userId: string) {
|
|
8
|
+
logger.info({ userId, action: "ai-request" }, "Processing AI request");
|
|
9
|
+
|
|
10
|
+
const response = await openai.chat.completions.create({
|
|
11
|
+
model: "gpt-4",
|
|
12
|
+
messages: [{ role: "user", content: prompt }],
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
logger.info({ userId, action: "ai-response", model: "gpt-4" }, "AI response generated");
|
|
16
|
+
return response;
|
|
17
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface ReviewDecision {
|
|
2
|
+
approved: boolean;
|
|
3
|
+
reviewerId: string;
|
|
4
|
+
timestamp: Date;
|
|
5
|
+
overrideReason?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function requireHumanApproval(_aiOutput: string): Promise<ReviewDecision> {
|
|
9
|
+
// Human review gate - AI output must be approved before action
|
|
10
|
+
return { approved: false, reviewerId: "", timestamp: new Date() };
|
|
11
|
+
}
|