@llm4ts/shell 2.12.0 → 2.13.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/flows/epic-stories.js +9 -3
- package/flows/lib/epic-stories.js +24 -4
- package/kits/soap-ace/README.md +313 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/RUNBOOK.md +102 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/design/api-design.md +620 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/responses/cercaConti.xml +3 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/responses/cercaMovimenti.xml +3 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/responses/confermaBonifico.xml +3 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/responses/dettaglioConto.xml +3 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/responses/inserisciBonifico.xml +3 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/responses/revocaBonifico.xml +3 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/soapui/DemoBank-soapui-project.xml +39 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/DemoBank.wsdl +186 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/LegacyRpc.wsdl +29 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/xsd/banking.xsd +10 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/xsd/bonifici.xsd +107 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/xsd/common.xsd +93 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/xsd/conti.xsd +71 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/xsd/movimenti.xsd +55 -0
- package/kits/soap-ace/flows/lib/soap/Analysis.js +508 -0
- package/kits/soap-ace/flows/lib/soap/Auth.js +179 -0
- package/kits/soap-ace/flows/lib/soap/Call.js +186 -0
- package/kits/soap-ace/flows/lib/soap/CallPolicy.js +50 -0
- package/kits/soap-ace/flows/lib/soap/Catalog.js +157 -0
- package/kits/soap-ace/flows/lib/soap/Classification.js +254 -0
- package/kits/soap-ace/flows/lib/soap/Design.js +497 -0
- package/kits/soap-ace/flows/lib/soap/DesignProposal.js +204 -0
- package/kits/soap-ace/flows/lib/soap/Discover.js +139 -0
- package/kits/soap-ace/flows/lib/soap/Envelope.js +132 -0
- package/kits/soap-ace/flows/lib/soap/Epic.js +239 -0
- package/kits/soap-ace/flows/lib/soap/Instance.js +675 -0
- package/kits/soap-ace/flows/lib/soap/Mapping.js +130 -0
- package/kits/soap-ace/flows/lib/soap/Masking.js +508 -0
- package/kits/soap-ace/flows/lib/soap/OpenApi.js +288 -0
- package/kits/soap-ace/flows/lib/soap/Samples.js +287 -0
- package/kits/soap-ace/flows/lib/soap/Scenarios.js +132 -0
- package/kits/soap-ace/flows/lib/soap/SoapUiImport.js +200 -0
- package/kits/soap-ace/flows/lib/soap/Stub.js +34 -0
- package/kits/soap-ace/flows/lib/soap/Transport.js +167 -0
- package/kits/soap-ace/flows/lib/soap/Wsdl.js +533 -0
- package/kits/soap-ace/flows/lib/soap/Xml.js +453 -0
- package/kits/soap-ace/flows/lib/soap/Xsd.js +471 -0
- package/kits/soap-ace/flows/lib/soap/Yaml.js +309 -0
- package/kits/soap-ace/flows/soap-design.js +167 -0
- package/kits/soap-ace/flows/soap-discover.js +85 -0
- package/kits/soap-ace/flows/soap-epic.js +204 -0
- package/kits/soap-ace/flows/soap-sample.js +265 -0
- package/kits/soap-ace/packs/ace12-rest/api-style.md +63 -0
- package/kits/soap-ace/packs/ace12-rest/pack.md +18 -0
- package/kits/soap-ace/packs/ace12-rest/prompts/implement.md +8 -0
- package/kits/soap-ace/packs/ace12-rest/reviewers/contract.md +5 -0
- package/kits/soap-ace/patterns/PAT-ACE-001-esito-to-problem.md +8 -0
- package/kits/soap-ace/patterns/PAT-ACE-002-fault-to-problem.md +8 -0
- package/kits/soap-ace/patterns/PAT-ACE-003-amounts-and-enums.md +8 -0
- package/kits/soap-ace/patterns/PAT-ACE-004-pagination.md +7 -0
- package/kits/soap-ace/patterns/PAT-ACE-005-nil-and-absence.md +7 -0
- package/kits/soap-ace/scaffolds/ace12-rest-api/CONTRIBUTING.md +56 -0
- package/kits/soap-ace/scaffolds/ace12-rest-api/README.md +16 -0
- package/kits/soap-ace/scaffolds/ace12-rest-api/scripts/ace-gates-container.sh +8 -0
- package/kits/soap-ace/scaffolds/ace12-rest-api/scripts/ace-gates.sh +41 -0
- package/package.json +4 -4
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import * as Schema from "effect/Schema";
|
|
2
|
+
import { elementByName, localName } from "./Catalog.js";
|
|
3
|
+
import { schemaPaths } from "./Analysis.js";
|
|
4
|
+
import { simpleView } from "./Instance.js";
|
|
5
|
+
// The deterministic 1:1 mapping: every XSD path of every request and
|
|
6
|
+
// response, with the JSON path and JSON type a literal facade would give it.
|
|
7
|
+
// It is the traceability base the REST design overlays: a design property
|
|
8
|
+
// cites one of these paths, and the design check verifies the path exists.
|
|
9
|
+
export class MappedField extends Schema.Class("MappedField")({
|
|
10
|
+
/** XSD path relative to the message element, `[]` for repeated elements. */
|
|
11
|
+
xsd: Schema.String,
|
|
12
|
+
/** The same field in a literal JSON facade, `$.a[*].b`. */
|
|
13
|
+
json: Schema.String,
|
|
14
|
+
jsonType: Schema.Literals(["string", "integer", "number", "boolean", "object", "array"]),
|
|
15
|
+
format: Schema.optionalKey(Schema.String),
|
|
16
|
+
xsdType: Schema.String,
|
|
17
|
+
required: Schema.Boolean,
|
|
18
|
+
repeated: Schema.Boolean,
|
|
19
|
+
enumeration: Schema.optionalKey(Schema.Array(Schema.String))
|
|
20
|
+
}) {
|
|
21
|
+
}
|
|
22
|
+
export class OperationMapping extends Schema.Class("OperationMapping")({
|
|
23
|
+
operation: Schema.String,
|
|
24
|
+
request: Schema.String,
|
|
25
|
+
response: Schema.optionalKey(Schema.String),
|
|
26
|
+
requestFields: Schema.Array(MappedField),
|
|
27
|
+
responseFields: Schema.Array(MappedField)
|
|
28
|
+
}) {
|
|
29
|
+
}
|
|
30
|
+
export class ServiceMapping extends Schema.Class("ServiceMapping")({
|
|
31
|
+
version: Schema.Literal(1),
|
|
32
|
+
operations: Schema.Array(OperationMapping)
|
|
33
|
+
}) {
|
|
34
|
+
}
|
|
35
|
+
const integerBuiltins = new Set([
|
|
36
|
+
"int",
|
|
37
|
+
"integer",
|
|
38
|
+
"short",
|
|
39
|
+
"byte",
|
|
40
|
+
"nonNegativeInteger",
|
|
41
|
+
"positiveInteger",
|
|
42
|
+
"nonPositiveInteger",
|
|
43
|
+
"negativeInteger",
|
|
44
|
+
"unsignedInt",
|
|
45
|
+
"unsignedShort",
|
|
46
|
+
"unsignedByte"
|
|
47
|
+
]);
|
|
48
|
+
/** JSON type and format for an XSD type; decimals stay strings (exact amounts). */
|
|
49
|
+
export const jsonTypeOf = (catalog, type) => {
|
|
50
|
+
const builtin = simpleView(catalog, type).builtin;
|
|
51
|
+
if (integerBuiltins.has(builtin))
|
|
52
|
+
return { jsonType: "integer", format: "int32" };
|
|
53
|
+
if (builtin === "long" || builtin === "unsignedLong")
|
|
54
|
+
return { jsonType: "integer", format: "int64" };
|
|
55
|
+
if (builtin === "decimal")
|
|
56
|
+
return { jsonType: "string", format: "decimal" };
|
|
57
|
+
if (builtin === "float" || builtin === "double")
|
|
58
|
+
return { jsonType: "number", format: builtin };
|
|
59
|
+
if (builtin === "boolean")
|
|
60
|
+
return { jsonType: "boolean" };
|
|
61
|
+
if (builtin === "date")
|
|
62
|
+
return { jsonType: "string", format: "date" };
|
|
63
|
+
if (builtin === "dateTime")
|
|
64
|
+
return { jsonType: "string", format: "date-time" };
|
|
65
|
+
if (builtin === "base64Binary")
|
|
66
|
+
return { jsonType: "string", format: "byte" };
|
|
67
|
+
return { jsonType: "string" };
|
|
68
|
+
};
|
|
69
|
+
const jsonPath = (xsd) => `$.${xsd.replace(/\[\]/g, "[*]").replace(/@/g, "")}`;
|
|
70
|
+
export const mapElement = (catalog, element) => schemaPaths(catalog, element).map((path) => {
|
|
71
|
+
const container = !path.leaf;
|
|
72
|
+
const typed = container ? { jsonType: "object" } : jsonTypeOf(catalog, path.type);
|
|
73
|
+
const enumeration = container
|
|
74
|
+
? undefined
|
|
75
|
+
: simpleView(catalog, path.type).facets.find((facets) => facets.enumeration !== undefined)
|
|
76
|
+
?.enumeration;
|
|
77
|
+
return new MappedField({
|
|
78
|
+
xsd: path.path,
|
|
79
|
+
json: jsonPath(path.path),
|
|
80
|
+
jsonType: path.repeated ? "array" : typed.jsonType,
|
|
81
|
+
...("format" in typed && typed.format !== undefined ? { format: typed.format } : {}),
|
|
82
|
+
xsdType: localName(path.type).replace(/^~/, ""),
|
|
83
|
+
required: !path.optional,
|
|
84
|
+
repeated: path.repeated,
|
|
85
|
+
...(enumeration === undefined ? {} : { enumeration })
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
export const mapService = (catalog) => new ServiceMapping({
|
|
89
|
+
version: 1,
|
|
90
|
+
operations: catalog.operations.map((operation) => {
|
|
91
|
+
const input = elementByName(catalog, operation.input);
|
|
92
|
+
const output = operation.output === undefined ? undefined : elementByName(catalog, operation.output);
|
|
93
|
+
return new OperationMapping({
|
|
94
|
+
operation: operation.name,
|
|
95
|
+
request: localName(operation.input),
|
|
96
|
+
...(operation.output === undefined ? {} : { response: localName(operation.output) }),
|
|
97
|
+
requestFields: input === undefined ? [] : mapElement(catalog, input),
|
|
98
|
+
responseFields: output === undefined ? [] : mapElement(catalog, output)
|
|
99
|
+
});
|
|
100
|
+
})
|
|
101
|
+
});
|
|
102
|
+
const row = (field) => `| ${field.xsd} | ${field.json} | ${field.jsonType}${field.format === undefined ? "" : ` (${field.format})`} | ${field.xsdType} | ${field.required ? "yes" : "no"} | ${field.enumeration?.join(", ") ?? ""} |`;
|
|
103
|
+
export const renderMapping = (mapping) => [
|
|
104
|
+
"# 1:1 mapping",
|
|
105
|
+
"",
|
|
106
|
+
"Every request and response field with the JSON path and type a literal facade would use.",
|
|
107
|
+
"The REST design cites these XSD paths; generated by soap-design, regenerate rather than edit.",
|
|
108
|
+
"",
|
|
109
|
+
...mapping.operations.flatMap((operation) => [
|
|
110
|
+
`## ${operation.operation}`,
|
|
111
|
+
"",
|
|
112
|
+
`Request \`${operation.request}\`:`,
|
|
113
|
+
"",
|
|
114
|
+
"| XSD path | JSON path | JSON type | XSD type | required | enumeration |",
|
|
115
|
+
"| --- | --- | --- | --- | --- | --- |",
|
|
116
|
+
...operation.requestFields.map(row),
|
|
117
|
+
"",
|
|
118
|
+
...(operation.response === undefined
|
|
119
|
+
? ["One-way operation: no response.", ""]
|
|
120
|
+
: [
|
|
121
|
+
`Response \`${operation.response}\`:`,
|
|
122
|
+
"",
|
|
123
|
+
"| XSD path | JSON path | JSON type | XSD type | required | enumeration |",
|
|
124
|
+
"| --- | --- | --- | --- | --- | --- |",
|
|
125
|
+
...operation.responseFields.map(row),
|
|
126
|
+
""
|
|
127
|
+
])
|
|
128
|
+
])
|
|
129
|
+
].join("\n");
|
|
130
|
+
export const encodeMapping = (mapping) => `${JSON.stringify(Schema.encodeSync(ServiceMapping)(mapping), null, 2)}\n`;
|
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
import * as Schema from "effect/Schema";
|
|
3
|
+
import { ComplexTypeDef, localName } from "./Catalog.js";
|
|
4
|
+
// Always-on masking of SOAP samples before they are persisted or shown to a
|
|
5
|
+
// model. Two signals decide what is personal data: the field (its element
|
|
6
|
+
// name, or its XSD type in the catalog) and the value itself (checksummed
|
|
7
|
+
// identifiers found anywhere in text). Every replacement is a keyed,
|
|
8
|
+
// deterministic, format-preserving pseudonym: the same input maps to the same
|
|
9
|
+
// output under one service key, so "the account in the request is the
|
|
10
|
+
// account echoed in the response" survives masking, and a masked IBAN, codice
|
|
11
|
+
// fiscale, partita IVA, or PAN still passes its checksum. There is no switch
|
|
12
|
+
// to turn this off.
|
|
13
|
+
export const MaskKind = Schema.Literals([
|
|
14
|
+
"iban",
|
|
15
|
+
"codice-fiscale",
|
|
16
|
+
"partita-iva",
|
|
17
|
+
"pan",
|
|
18
|
+
"email",
|
|
19
|
+
"phone",
|
|
20
|
+
"personal-text",
|
|
21
|
+
"birth-date",
|
|
22
|
+
"secret"
|
|
23
|
+
]);
|
|
24
|
+
export const MaskOverride = Schema.Literals(["mask", "keep"]);
|
|
25
|
+
/** `masking.json`: per-field overrides keyed by element local name. */
|
|
26
|
+
export class MaskingOverrides extends Schema.Class("MaskingOverrides")({
|
|
27
|
+
fields: Schema.Record(Schema.String, MaskOverride)
|
|
28
|
+
}) {
|
|
29
|
+
}
|
|
30
|
+
export const MaskSource = Schema.Literals(["field-name", "field-type", "value", "override"]);
|
|
31
|
+
export class MaskEntry extends Schema.Class("MaskEntry")({
|
|
32
|
+
/** Element path from the document root, local names joined by `/`. */
|
|
33
|
+
path: Schema.String,
|
|
34
|
+
kind: MaskKind,
|
|
35
|
+
source: MaskSource,
|
|
36
|
+
count: Schema.Int
|
|
37
|
+
}) {
|
|
38
|
+
}
|
|
39
|
+
export class MaskingReport extends Schema.Class("MaskingReport")({
|
|
40
|
+
entries: Schema.Array(MaskEntry),
|
|
41
|
+
/** Fields a `keep` override left untouched even though a rule matched. */
|
|
42
|
+
kept: Schema.Array(Schema.String)
|
|
43
|
+
}) {
|
|
44
|
+
}
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Checksums
|
|
47
|
+
const digitsOnly = /^\d+$/;
|
|
48
|
+
/** Italian CIN / codice fiscale tables: value of a character at an odd (1-based) position. */
|
|
49
|
+
const oddValues = (() => {
|
|
50
|
+
const table = [
|
|
51
|
+
1, 0, 5, 7, 9, 13, 15, 17, 19, 21, 2, 4, 18, 20, 11, 3, 6, 8, 12, 14, 16, 10, 22, 25, 24, 23
|
|
52
|
+
];
|
|
53
|
+
const values = {};
|
|
54
|
+
for (let index = 0; index < 10; index++)
|
|
55
|
+
values[String(index)] = table[index] ?? 0;
|
|
56
|
+
for (let index = 0; index < 26; index++) {
|
|
57
|
+
values[String.fromCharCode(65 + index)] = table[index] ?? 0;
|
|
58
|
+
}
|
|
59
|
+
return values;
|
|
60
|
+
})();
|
|
61
|
+
const evenValue = (char) => /\d/.test(char) ? Number(char) : char.charCodeAt(0) - 65;
|
|
62
|
+
/** Check letter over `chars` using the codice fiscale / CIN odd-even tables. */
|
|
63
|
+
export const italianCheckLetter = (chars) => {
|
|
64
|
+
let sum = 0;
|
|
65
|
+
for (let index = 0; index < chars.length; index++) {
|
|
66
|
+
const char = chars[index] ?? "0";
|
|
67
|
+
sum += index % 2 === 0 ? (oddValues[char] ?? 0) : evenValue(char);
|
|
68
|
+
}
|
|
69
|
+
return String.fromCharCode(65 + (sum % 26));
|
|
70
|
+
};
|
|
71
|
+
const ibanRemainder = (iban) => {
|
|
72
|
+
const rearranged = `${iban.slice(4)}${iban.slice(0, 4)}`;
|
|
73
|
+
let remainder = 0;
|
|
74
|
+
for (const char of rearranged) {
|
|
75
|
+
const value = /\d/.test(char) ? char : String(char.charCodeAt(0) - 55);
|
|
76
|
+
for (const digit of value)
|
|
77
|
+
remainder = (remainder * 10 + Number(digit)) % 97;
|
|
78
|
+
}
|
|
79
|
+
return remainder;
|
|
80
|
+
};
|
|
81
|
+
export const isValidIban = (value) => /^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/.test(value) && ibanRemainder(value) === 1;
|
|
82
|
+
const withIbanCheckDigits = (country, bban) => {
|
|
83
|
+
const check = 98 - ibanRemainder(`${country}00${bban}`);
|
|
84
|
+
return `${country}${String(check).padStart(2, "0")}${bban}`;
|
|
85
|
+
};
|
|
86
|
+
const cfPattern = /^[A-Z]{6}[0-9LMNPQRSTUV]{2}[ABCDEHLMPRST][0-9LMNPQRSTUV]{2}[A-Z][0-9LMNPQRSTUV]{3}[A-Z]$/;
|
|
87
|
+
export const isValidCodiceFiscale = (value) => cfPattern.test(value) && italianCheckLetter(value.slice(0, 15)) === value[15];
|
|
88
|
+
const luhnSum = (digits, doubleFirst) => {
|
|
89
|
+
let sum = 0;
|
|
90
|
+
let double = doubleFirst;
|
|
91
|
+
for (let index = digits.length - 1; index >= 0; index--) {
|
|
92
|
+
let digit = Number(digits[index]);
|
|
93
|
+
if (double) {
|
|
94
|
+
digit *= 2;
|
|
95
|
+
if (digit > 9)
|
|
96
|
+
digit -= 9;
|
|
97
|
+
}
|
|
98
|
+
sum += digit;
|
|
99
|
+
double = !double;
|
|
100
|
+
}
|
|
101
|
+
return sum;
|
|
102
|
+
};
|
|
103
|
+
export const isValidLuhn = (value) => digitsOnly.test(value) && luhnSum(value, false) % 10 === 0;
|
|
104
|
+
const luhnCheckDigit = (payload) => String((10 - (luhnSum(payload, true) % 10)) % 10);
|
|
105
|
+
export const isValidPan = (value) => /^[2-6]\d{12,18}$/.test(value) && isValidLuhn(value);
|
|
106
|
+
const partitaIvaCheck = (first10) => {
|
|
107
|
+
let sum = 0;
|
|
108
|
+
for (let index = 0; index < 10; index++) {
|
|
109
|
+
let digit = Number(first10[index]);
|
|
110
|
+
if (index % 2 === 1) {
|
|
111
|
+
digit *= 2;
|
|
112
|
+
if (digit > 9)
|
|
113
|
+
digit -= 9;
|
|
114
|
+
}
|
|
115
|
+
sum += digit;
|
|
116
|
+
}
|
|
117
|
+
return String((10 - (sum % 10)) % 10);
|
|
118
|
+
};
|
|
119
|
+
export const isValidPartitaIva = (value) => /^\d{11}$/.test(value) && partitaIvaCheck(value.slice(0, 10)) === value[10];
|
|
120
|
+
const makeDrawer = (key, kind, value) => {
|
|
121
|
+
let block = 0;
|
|
122
|
+
let bytes = new Uint8Array(0);
|
|
123
|
+
let offset = 0;
|
|
124
|
+
const next = () => {
|
|
125
|
+
if (offset >= bytes.length) {
|
|
126
|
+
bytes = createHmac("sha256", key).update(`${kind}\u0000${value}\u0000${block}`).digest();
|
|
127
|
+
block++;
|
|
128
|
+
offset = 0;
|
|
129
|
+
}
|
|
130
|
+
const byte = bytes[offset] ?? 0;
|
|
131
|
+
offset++;
|
|
132
|
+
return byte;
|
|
133
|
+
};
|
|
134
|
+
const int = (bound) => {
|
|
135
|
+
// Rejection sampling over two bytes keeps the draw unbiased.
|
|
136
|
+
const limit = 65536 - (65536 % bound);
|
|
137
|
+
for (;;) {
|
|
138
|
+
const candidate = next() * 256 + next();
|
|
139
|
+
if (candidate < limit)
|
|
140
|
+
return candidate % bound;
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
return {
|
|
144
|
+
digit: () => String(int(10)),
|
|
145
|
+
letter: () => String.fromCharCode(65 + int(26)),
|
|
146
|
+
int
|
|
147
|
+
};
|
|
148
|
+
};
|
|
149
|
+
/** Replace every letter and digit by one of the same class and case; keep the rest. */
|
|
150
|
+
const scramble = (draw, value) => Array.from(value, (char) => {
|
|
151
|
+
if (/\d/.test(char))
|
|
152
|
+
return draw.digit();
|
|
153
|
+
if (/[A-Z]/.test(char))
|
|
154
|
+
return draw.letter();
|
|
155
|
+
if (/[a-z]/.test(char))
|
|
156
|
+
return draw.letter().toLowerCase();
|
|
157
|
+
if (/\p{Lu}/u.test(char))
|
|
158
|
+
return draw.letter();
|
|
159
|
+
if (/\p{Ll}/u.test(char))
|
|
160
|
+
return draw.letter().toLowerCase();
|
|
161
|
+
return char;
|
|
162
|
+
}).join("");
|
|
163
|
+
const omocodia = {
|
|
164
|
+
L: "0",
|
|
165
|
+
M: "1",
|
|
166
|
+
N: "2",
|
|
167
|
+
P: "3",
|
|
168
|
+
Q: "4",
|
|
169
|
+
R: "5",
|
|
170
|
+
S: "6",
|
|
171
|
+
T: "7",
|
|
172
|
+
U: "8",
|
|
173
|
+
V: "9"
|
|
174
|
+
};
|
|
175
|
+
const pseudonyms = {
|
|
176
|
+
// Country, ABI and CAB are bank data, not personal: kept, so analysis can
|
|
177
|
+
// still tell banks apart. The account part is replaced class for class and
|
|
178
|
+
// the Italian CIN and the IBAN check digits are recomputed.
|
|
179
|
+
iban: (draw, value) => {
|
|
180
|
+
const country = value.slice(0, 2);
|
|
181
|
+
if (country === "IT" && value.length === 27) {
|
|
182
|
+
const abiCab = value.slice(5, 15);
|
|
183
|
+
const account = scramble(draw, value.slice(15));
|
|
184
|
+
const cin = italianCheckLetter(`${abiCab}${account}`);
|
|
185
|
+
return withIbanCheckDigits("IT", `${cin}${abiCab}${account}`);
|
|
186
|
+
}
|
|
187
|
+
return withIbanCheckDigits(country, scramble(draw, value.slice(4)));
|
|
188
|
+
},
|
|
189
|
+
// New surname/name letters, birth year, month, day (sex kept), and comune;
|
|
190
|
+
// omocodia is normalized away; the check letter is recomputed.
|
|
191
|
+
"codice-fiscale": (draw, value) => {
|
|
192
|
+
const letters = Array.from({ length: 6 }, () => draw.letter()).join("");
|
|
193
|
+
const year = `${draw.digit()}${draw.digit()}`;
|
|
194
|
+
const month = "ABCDEHLMPRST"[draw.int(12)] ?? "A";
|
|
195
|
+
const originalDay = Number(Array.from(value.slice(9, 11), (char) => omocodia[char] ?? char).join(""));
|
|
196
|
+
const day = String(1 + draw.int(28) + (originalDay > 40 ? 40 : 0)).padStart(2, "0");
|
|
197
|
+
const comune = `${draw.letter()}${draw.digit()}${draw.digit()}${draw.digit()}`;
|
|
198
|
+
const body = `${letters}${year}${month}${day}${comune}`;
|
|
199
|
+
return `${body}${italianCheckLetter(body)}`;
|
|
200
|
+
},
|
|
201
|
+
// Matricola replaced, provincial office code kept, check digit recomputed.
|
|
202
|
+
"partita-iva": (draw, value) => {
|
|
203
|
+
const first10 = `${Array.from({ length: 7 }, () => draw.digit()).join("")}${value.slice(7, 10)}`;
|
|
204
|
+
return `${first10}${partitaIvaCheck(first10)}`;
|
|
205
|
+
},
|
|
206
|
+
// Issuer BIN (first six) kept, account digits replaced, Luhn recomputed.
|
|
207
|
+
pan: (draw, value) => {
|
|
208
|
+
const payload = `${value.slice(0, 6)}${Array.from({ length: value.length - 7 }, () => draw.digit()).join("")}`;
|
|
209
|
+
return `${payload}${luhnCheckDigit(payload)}`;
|
|
210
|
+
},
|
|
211
|
+
email: (draw, value) => {
|
|
212
|
+
const at = value.lastIndexOf("@");
|
|
213
|
+
return `${scramble(draw, value.slice(0, at))}@example.invalid`;
|
|
214
|
+
},
|
|
215
|
+
// International prefix and the first three digits (operator or area) kept.
|
|
216
|
+
phone: (draw, value) => {
|
|
217
|
+
const prefix = /^(\+\d{2}|00\d{2})?\s*\d{0,3}/.exec(value)?.[0] ?? "";
|
|
218
|
+
return `${prefix}${scramble(draw, value.slice(prefix.length))}`;
|
|
219
|
+
},
|
|
220
|
+
"personal-text": scramble,
|
|
221
|
+
// Shifted by up to a year either way; format kept.
|
|
222
|
+
"birth-date": (draw, value) => {
|
|
223
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})(.*)$/.exec(value);
|
|
224
|
+
if (match === null)
|
|
225
|
+
return scramble(draw, value);
|
|
226
|
+
const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])));
|
|
227
|
+
date.setUTCDate(date.getUTCDate() + draw.int(731) - 365);
|
|
228
|
+
return `${date.toISOString().slice(0, 10)}${match[4] ?? ""}`;
|
|
229
|
+
},
|
|
230
|
+
secret: scramble
|
|
231
|
+
};
|
|
232
|
+
// A field named for one kind can hold another shape (a company's
|
|
233
|
+
// `codiceFiscale` is its 11-digit partita IVA). The structured pseudonym
|
|
234
|
+
// only applies when the value has its kind's shape; otherwise the value is
|
|
235
|
+
// scrambled class for class, which keeps its length and character classes.
|
|
236
|
+
const shapes = {
|
|
237
|
+
iban: /^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/,
|
|
238
|
+
"codice-fiscale": cfPattern,
|
|
239
|
+
"partita-iva": /^\d{11}$/,
|
|
240
|
+
pan: /^\d{13,19}$/,
|
|
241
|
+
email: /^[^@\s]+@[^@\s]+$/
|
|
242
|
+
};
|
|
243
|
+
const effectiveKind = (kind, value) => {
|
|
244
|
+
const shape = shapes[kind];
|
|
245
|
+
if (shape === undefined || shape.test(value))
|
|
246
|
+
return kind;
|
|
247
|
+
if (kind === "codice-fiscale" && /^\d{11}$/.test(value))
|
|
248
|
+
return "partita-iva";
|
|
249
|
+
return "personal-text";
|
|
250
|
+
};
|
|
251
|
+
/** The pseudonym of one value; stable for a given key. */
|
|
252
|
+
export const pseudonymize = (key, kind, value) => {
|
|
253
|
+
const effective = effectiveKind(kind, value);
|
|
254
|
+
return pseudonyms[effective](makeDrawer(key, effective, value), value);
|
|
255
|
+
};
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
// Detection
|
|
258
|
+
/** camelCase, PascalCase, snake_case and kebab-case words, lower-cased. */
|
|
259
|
+
export const nameWords = (name) => name
|
|
260
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
261
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
|
|
262
|
+
.split(/[\s_\-.]+/)
|
|
263
|
+
.filter((word) => word !== "")
|
|
264
|
+
.map((word) => word.toLowerCase());
|
|
265
|
+
const wordRules = [
|
|
266
|
+
["iban", ["iban"]],
|
|
267
|
+
["codice-fiscale", ["cf", "codicefiscale", "codfisc", "fiscalcode", "taxcode"]],
|
|
268
|
+
["partita-iva", ["piva", "partitaiva", "vat", "vatnumber"]],
|
|
269
|
+
["pan", ["pan", "cardnumber"]],
|
|
270
|
+
["email", ["email", "mail", "pec"]],
|
|
271
|
+
["phone", ["telefono", "cellulare", "phone", "mobile", "fax", "tel"]],
|
|
272
|
+
["birth-date", []],
|
|
273
|
+
["secret", ["otp", "pin", "cvv", "cvc", "password", "pwd", "segreto"]],
|
|
274
|
+
[
|
|
275
|
+
"personal-text",
|
|
276
|
+
[
|
|
277
|
+
"nome",
|
|
278
|
+
"cognome",
|
|
279
|
+
"intestatario",
|
|
280
|
+
"cointestatario",
|
|
281
|
+
"cointestatari",
|
|
282
|
+
"nominativo",
|
|
283
|
+
"nominativi",
|
|
284
|
+
"firmatario",
|
|
285
|
+
"delegato",
|
|
286
|
+
"referente",
|
|
287
|
+
"beneficiario",
|
|
288
|
+
"ordinante",
|
|
289
|
+
"titolare",
|
|
290
|
+
"ragione",
|
|
291
|
+
"denominazione",
|
|
292
|
+
"name",
|
|
293
|
+
"surname",
|
|
294
|
+
"firstname",
|
|
295
|
+
"lastname",
|
|
296
|
+
"fullname",
|
|
297
|
+
"holder",
|
|
298
|
+
"indirizzo",
|
|
299
|
+
"via",
|
|
300
|
+
"civico",
|
|
301
|
+
"cap",
|
|
302
|
+
"localita",
|
|
303
|
+
"residenza",
|
|
304
|
+
"address",
|
|
305
|
+
"street",
|
|
306
|
+
"zip",
|
|
307
|
+
"postcode"
|
|
308
|
+
]
|
|
309
|
+
]
|
|
310
|
+
];
|
|
311
|
+
// Multi-word names whose words alone would not match.
|
|
312
|
+
const phraseRules = [
|
|
313
|
+
["codice-fiscale", /^codice fiscale$|^cod fisc$/],
|
|
314
|
+
["partita-iva", /partita iva/],
|
|
315
|
+
["pan", /numero carta|card number|pan carta/],
|
|
316
|
+
["birth-date", /data (di )?nascita|birth ?date|date of birth|dob/],
|
|
317
|
+
["personal-text", /luogo (di )?nascita|ragione sociale|place of birth/],
|
|
318
|
+
["secret", /codice (otp|pin|segreto)/]
|
|
319
|
+
];
|
|
320
|
+
/** The kind a field's name implies, if any. */
|
|
321
|
+
export const kindForName = (name) => {
|
|
322
|
+
const words = nameWords(name);
|
|
323
|
+
const phrase = words.join(" ");
|
|
324
|
+
for (const [kind, pattern] of phraseRules)
|
|
325
|
+
if (pattern.test(phrase))
|
|
326
|
+
return kind;
|
|
327
|
+
const joined = words.join("");
|
|
328
|
+
for (const [kind, candidates] of wordRules) {
|
|
329
|
+
if (candidates.includes(joined) || words.some((word) => candidates.includes(word)))
|
|
330
|
+
return kind;
|
|
331
|
+
}
|
|
332
|
+
return undefined;
|
|
333
|
+
};
|
|
334
|
+
/**
|
|
335
|
+
* Field names whose declared XSD type implies a kind (`iban` of type
|
|
336
|
+
* `IbanType`, `cf` of type `CodiceFiscaleType`), from the catalog.
|
|
337
|
+
*/
|
|
338
|
+
export const kindsFromCatalog = (catalog) => {
|
|
339
|
+
const kinds = new Map();
|
|
340
|
+
for (const type of catalog.types) {
|
|
341
|
+
if (!(type instanceof ComplexTypeDef))
|
|
342
|
+
continue;
|
|
343
|
+
for (const field of type.fields) {
|
|
344
|
+
const kind = kindForName(localName(field.type).replace(/Type$/, ""));
|
|
345
|
+
if (kind !== undefined && !kinds.has(field.name))
|
|
346
|
+
kinds.set(field.name, kind);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return kinds;
|
|
350
|
+
};
|
|
351
|
+
const compact = (value) => value.replace(/\s+/g, "").toUpperCase();
|
|
352
|
+
// Identifiers as people type them: upper or lower case, IBANs in groups of
|
|
353
|
+
// four. A match is normalized (spaces out, upper case) before the checksum
|
|
354
|
+
// and the pseudonym, so every spelling of one IBAN maps to one pseudonym.
|
|
355
|
+
const valueDetectors = [
|
|
356
|
+
{
|
|
357
|
+
kind: "iban",
|
|
358
|
+
// Compact, or in groups of four as printed on statements.
|
|
359
|
+
pattern: /\b[A-Za-z]{2}\d{2}(?:(?: [A-Za-z0-9]{4}){2,7}(?: [A-Za-z0-9]{1,3})?|[A-Za-z0-9]{11,30})\b/g,
|
|
360
|
+
normalize: compact,
|
|
361
|
+
accept: isValidIban
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
kind: "codice-fiscale",
|
|
365
|
+
pattern: /\b[A-Za-z]{6}[0-9LMNPQRSTUVlmnpqrstuv]{2}[A-Za-z][0-9LMNPQRSTUVlmnpqrstuv]{2}[A-Za-z][0-9LMNPQRSTUVlmnpqrstuv]{3}[A-Za-z]\b/g,
|
|
366
|
+
normalize: compact,
|
|
367
|
+
accept: isValidCodiceFiscale
|
|
368
|
+
},
|
|
369
|
+
{ kind: "pan", pattern: /\b[2-6]\d{12,18}\b/g, normalize: (match) => match, accept: isValidPan },
|
|
370
|
+
{
|
|
371
|
+
kind: "partita-iva",
|
|
372
|
+
pattern: /(?<=\bIT ?)\d{11}\b/gi,
|
|
373
|
+
normalize: (match) => match,
|
|
374
|
+
accept: isValidPartitaIva
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
kind: "email",
|
|
378
|
+
pattern: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g,
|
|
379
|
+
normalize: (match) => match,
|
|
380
|
+
accept: () => true
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
kind: "phone",
|
|
384
|
+
pattern: /(?:\+39|0039)[\s-]?(?:\d[\s-]?){6,11}\d/g,
|
|
385
|
+
normalize: (match) => match,
|
|
386
|
+
accept: () => true
|
|
387
|
+
}
|
|
388
|
+
];
|
|
389
|
+
class Tally {
|
|
390
|
+
counts = new Map();
|
|
391
|
+
kept = new Set();
|
|
392
|
+
add(path, kind, source) {
|
|
393
|
+
const key = `${path}\u0000${kind}\u0000${source}`;
|
|
394
|
+
const current = this.counts.get(key);
|
|
395
|
+
if (current === undefined)
|
|
396
|
+
this.counts.set(key, { path, kind, source, count: 1 });
|
|
397
|
+
else
|
|
398
|
+
current.count++;
|
|
399
|
+
}
|
|
400
|
+
report() {
|
|
401
|
+
return new MaskingReport({
|
|
402
|
+
entries: [...this.counts.values()]
|
|
403
|
+
.sort((left, right) => left.path.localeCompare(right.path) || left.kind.localeCompare(right.kind))
|
|
404
|
+
.map((entry) => new MaskEntry(entry)),
|
|
405
|
+
kept: [...this.kept].sort()
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Mask detected identifiers inside free text; returns the text and what was
|
|
411
|
+
* found. Every detector runs on the original text and overlapping matches
|
|
412
|
+
* go to the earlier detector, so a pseudonym is never masked again.
|
|
413
|
+
*/
|
|
414
|
+
export const maskText = (key, text) => {
|
|
415
|
+
const found = [];
|
|
416
|
+
for (const detector of valueDetectors) {
|
|
417
|
+
for (const match of text.matchAll(detector.pattern)) {
|
|
418
|
+
const start = match.index;
|
|
419
|
+
const end = start + match[0].length;
|
|
420
|
+
const normalized = detector.normalize(match[0]);
|
|
421
|
+
if (!detector.accept(normalized))
|
|
422
|
+
continue;
|
|
423
|
+
if (found.some((other) => start < other.end && end > other.start))
|
|
424
|
+
continue;
|
|
425
|
+
found.push({ start, end, kind: detector.kind, normalized });
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
found.sort((left, right) => left.start - right.start);
|
|
429
|
+
let result = "";
|
|
430
|
+
let cursor = 0;
|
|
431
|
+
for (const match of found) {
|
|
432
|
+
result += text.slice(cursor, match.start) + pseudonymize(key, match.kind, match.normalized);
|
|
433
|
+
cursor = match.end;
|
|
434
|
+
}
|
|
435
|
+
return { text: result + text.slice(cursor), kinds: found.map((match) => match.kind) };
|
|
436
|
+
};
|
|
437
|
+
const ruleFor = (name, options) => {
|
|
438
|
+
const override = options.overrides?.fields[name];
|
|
439
|
+
const named = kindForName(name);
|
|
440
|
+
const typed = options.typeKinds?.get(name);
|
|
441
|
+
const kind = named ?? typed;
|
|
442
|
+
if (override === "keep")
|
|
443
|
+
return { kind, source: undefined, keep: true };
|
|
444
|
+
if (override === "mask") {
|
|
445
|
+
return { kind: kind ?? "personal-text", source: "override", keep: false };
|
|
446
|
+
}
|
|
447
|
+
return {
|
|
448
|
+
kind,
|
|
449
|
+
source: named !== undefined ? "field-name" : typed !== undefined ? "field-type" : undefined,
|
|
450
|
+
keep: false
|
|
451
|
+
};
|
|
452
|
+
};
|
|
453
|
+
/**
|
|
454
|
+
* Mask one parsed document (a SOAP envelope, request or response). Returns
|
|
455
|
+
* the masked tree and the report of what was replaced where. Element and
|
|
456
|
+
* attribute names, structure, and namespaces are never changed.
|
|
457
|
+
*/
|
|
458
|
+
export const maskDocument = (root, options) => {
|
|
459
|
+
const tally = new Tally();
|
|
460
|
+
const maskValue = (value, path, rule) => {
|
|
461
|
+
if (rule.keep) {
|
|
462
|
+
if (rule.kind !== undefined || maskText(options.key, value).kinds.length > 0) {
|
|
463
|
+
tally.kept.add(path);
|
|
464
|
+
}
|
|
465
|
+
return value;
|
|
466
|
+
}
|
|
467
|
+
if (rule.kind !== undefined && rule.source !== undefined && value.trim() !== "") {
|
|
468
|
+
tally.add(path, rule.kind, rule.source);
|
|
469
|
+
const leading = /^\s*/.exec(value)?.[0] ?? "";
|
|
470
|
+
const trailing = /\s*$/.exec(value)?.[0] ?? "";
|
|
471
|
+
const core = value.trim();
|
|
472
|
+
return `${leading}${pseudonymize(options.key, rule.kind, core)}${trailing}`;
|
|
473
|
+
}
|
|
474
|
+
const found = maskText(options.key, value);
|
|
475
|
+
for (const kind of found.kinds)
|
|
476
|
+
tally.add(path, kind, "value");
|
|
477
|
+
return found.text;
|
|
478
|
+
};
|
|
479
|
+
const walk = (element, parentPath) => {
|
|
480
|
+
const path = parentPath === "" ? element.name.local : `${parentPath}/${element.name.local}`;
|
|
481
|
+
const rule = ruleFor(element.name.local, options);
|
|
482
|
+
const attributes = element.attributes.map((attribute) => {
|
|
483
|
+
const attributePath = `${path}/@${attribute.name.local}`;
|
|
484
|
+
return {
|
|
485
|
+
...attribute,
|
|
486
|
+
value: maskValue(attribute.value, attributePath, ruleFor(attribute.name.local, options))
|
|
487
|
+
};
|
|
488
|
+
});
|
|
489
|
+
const children = element.children.map((child) => child._tag === "text"
|
|
490
|
+
? { _tag: "text", value: maskValue(child.value, path, rule) }
|
|
491
|
+
: walk(child, path));
|
|
492
|
+
return { ...element, attributes, children };
|
|
493
|
+
};
|
|
494
|
+
return { document: walk(root, ""), report: tally.report() };
|
|
495
|
+
};
|
|
496
|
+
/** Merge per-document reports into one (counts summed per path, kind, source). */
|
|
497
|
+
export const mergeReports = (reports) => {
|
|
498
|
+
const tally = new Tally();
|
|
499
|
+
for (const report of reports) {
|
|
500
|
+
for (const entry of report.entries) {
|
|
501
|
+
for (let index = 0; index < entry.count; index++)
|
|
502
|
+
tally.add(entry.path, entry.kind, entry.source);
|
|
503
|
+
}
|
|
504
|
+
for (const kept of report.kept)
|
|
505
|
+
tally.kept.add(kept);
|
|
506
|
+
}
|
|
507
|
+
return tally.report();
|
|
508
|
+
};
|