@slicedearth/whoisleuth-cli 2.1.0 → 2.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/README.md +2 -1
- package/cli/arguments.mjs +389 -1745
- package/cli/artifact-validation/case-response.mjs +55 -30
- package/cli/artifact-validation/signed-review.mjs +30 -12
- package/cli/bulk-checkpoint.mjs +34 -3
- package/cli/case-pack.mjs +39 -92
- package/cli/command-argument-grammar.mjs +211 -0
- package/cli/command-reference.mjs +200 -213
- package/cli/completion.mjs +5 -1
- package/cli/discovery-observation-snapshot.mjs +16 -3
- package/cli/discovery-scan-command-runner.mjs +19 -0
- package/cli/discovery-scan.mjs +6 -0
- package/cli/domain-control-observations.mjs +1 -1
- package/cli/inline-command-families.mjs +1 -0
- package/cli/input.mjs +116 -0
- package/cli/interchange-report.mjs +14 -0
- package/cli/lookalike-calibration.mjs +7 -2
- package/cli/lookup-brief.mjs +4 -2
- package/cli/lookup-browser-view.mjs +1 -1
- package/cli/mail-header-review.mjs +467 -0
- package/cli/review-command-runner.mjs +25 -0
- package/cli/risk-calibration.mjs +19 -17
- package/cli/runner.mjs +7 -1
- package/docs/cli-reference.md +8 -1
- package/docs/cli.md +24 -1
- package/lib/bounded-json.mjs +9 -6
- package/lib/dns-convergence-review.mjs +1 -0
- package/lib/dns-intelligence.mjs +37 -3
- package/lib/dnssec-chain-validation.mjs +6 -0
- package/lib/domain-change-packet.mjs +42 -14
- package/lib/domain-control-flight-recorder.mjs +4 -1
- package/lib/domain-control-manifest.mjs +16 -16
- package/lib/domain-posture-parsers.mjs +114 -3
- package/lib/domain-posture.mjs +34 -3
- package/lib/evidence-export.mjs +63 -13
- package/lib/evidence-report.mjs +2 -2
- package/lib/html-signals.mjs +39 -33
- package/lib/local-geoip-evidence.mjs +19 -3
- package/lib/lookup-response.mjs +5 -4
- package/lib/lookup-source-progress.mjs +27 -4
- package/lib/nameserver-preflight-review.mjs +1 -0
- package/lib/rdap-capabilities.mjs +1 -1
- package/lib/registry-insights.mjs +35 -22
- package/lib/risk-calibration-summary.mjs +1 -3
- package/lib/security-txt.mjs +33 -1
- package/lib/static-html-analysis.mjs +69 -13
- package/lib/website-technology.mjs +79 -11
- package/lib/zone-intent-review.mjs +16 -6
- package/package.json +1 -1
- package/packages/cases/case-migration-model.mjs +71 -22
- package/packages/cases/case-record-contracts.mjs +5 -20
- package/packages/cases/case-record-core.mjs +10 -21
- package/packages/cases/case-record-decisions.mjs +72 -0
- package/packages/cases/case-record-model.mjs +1 -0
- package/packages/cases/case-record-operations.mjs +240 -3
- package/packages/cases/case-record-projection.mjs +218 -0
- package/packages/cases/case-report.mjs +11 -6
- package/packages/cases/case-response-model.mjs +43 -6
- package/packages/cases/case-response-packet.mjs +78 -27
- package/packages/cases/case-storage-model.mjs +6 -2
- package/packages/comparison/comparison-ledger-bulk.mjs +9 -10
- package/packages/comparison/page-similarity.mjs +3 -1
- package/packages/contracts/case-portability.mjs +183 -53
- package/packages/contracts/cli-command-semantics.mjs +93 -0
- package/packages/contracts/risk-calibration.mjs +15 -10
- package/packages/contracts/workspace-portability.mjs +44 -7
- package/packages/evidence/domain-control-runtime.mjs +46 -6
- package/packages/interchange/external-findings-import.mjs +115 -46
- package/packages/investigation/lookup-asset-graph.mjs +35 -27
- package/packages/workspace/brand-profile-model.mjs +118 -2
- package/packages/workspace/bulk-review-model.mjs +13 -7
- package/packages/workspace/bulk-session-model.mjs +5 -4
- package/packages/workspace/detection-rule-model.mjs +70 -22
- package/packages/workspace/relationship-observation-model.mjs +4 -1
- package/packages/workspace/website-snapshot-model.mjs +106 -8
- package/packages/workspace/workspace-archive.mjs +2 -0
- package/cli/evidence-command-arguments.mjs +0 -156
|
@@ -130,13 +130,16 @@ function hostname(value) {
|
|
|
130
130
|
return null;
|
|
131
131
|
return candidate;
|
|
132
132
|
}
|
|
133
|
-
function
|
|
133
|
+
function webOrigin(value) {
|
|
134
134
|
const candidate = text(value, 2_048);
|
|
135
135
|
if (!candidate)
|
|
136
136
|
return null;
|
|
137
137
|
try {
|
|
138
|
-
|
|
139
|
-
|
|
138
|
+
const parsed = new URL(/^[a-z][a-z\d+.-]*:\/\//iu.test(candidate) ? candidate : `https://${candidate}`);
|
|
139
|
+
const parsedHostname = hostname(parsed.hostname);
|
|
140
|
+
return parsedHostname && parsed.origin !== 'null'
|
|
141
|
+
? Object.freeze({ origin: parsed.origin, hostname: parsedHostname })
|
|
142
|
+
: null;
|
|
140
143
|
}
|
|
141
144
|
catch {
|
|
142
145
|
return null;
|
|
@@ -233,12 +236,12 @@ export function buildLookupAssetGraph(input) {
|
|
|
233
236
|
...textList(input.profileDomains?.partner, 32),
|
|
234
237
|
...textList(input.profileDomains?.allowlisted, 32),
|
|
235
238
|
].map((value) => value.toLowerCase().replace(/\.$/u, '')));
|
|
236
|
-
const trustBoundary = (candidate, sourceHost = target) => {
|
|
239
|
+
const trustBoundary = (candidate, sourceHost = target, candidateOrigin = null, sourceOrigin = null) => {
|
|
237
240
|
if (!candidate)
|
|
238
241
|
return 'unresolved';
|
|
239
|
-
if (
|
|
242
|
+
if (candidateOrigin && sourceOrigin && candidateOrigin === sourceOrigin)
|
|
240
243
|
return 'same_origin';
|
|
241
|
-
if (candidate === target || candidate.endsWith(`.${target}`))
|
|
244
|
+
if (candidate === sourceHost || candidate === target || candidate.endsWith(`.${target}`))
|
|
242
245
|
return 'same_registrable_domain';
|
|
243
246
|
if (reviewedProfileDomains.has(candidate))
|
|
244
247
|
return 'reviewed_profile';
|
|
@@ -414,7 +417,8 @@ export function buildLookupAssetGraph(input) {
|
|
|
414
417
|
}
|
|
415
418
|
}
|
|
416
419
|
const httpEvidence = record(input.httpEvidence);
|
|
417
|
-
const
|
|
420
|
+
const finalOrigin = webOrigin(httpEvidence.finalUrl);
|
|
421
|
+
const finalHost = finalOrigin?.hostname ?? null;
|
|
418
422
|
const finalHostId = finalHost
|
|
419
423
|
? connect('hostname', finalHost, {
|
|
420
424
|
kind: 'redirects-to',
|
|
@@ -429,9 +433,9 @@ export function buildLookupAssetGraph(input) {
|
|
|
429
433
|
}, 'Observed final website origin')
|
|
430
434
|
: null;
|
|
431
435
|
const identitySource = finalHostId || targetId;
|
|
432
|
-
const
|
|
433
|
-
if (
|
|
434
|
-
connect('origin',
|
|
436
|
+
const canonicalOrigin = webOrigin(record(input.pageCanonical).url);
|
|
437
|
+
if (canonicalOrigin) {
|
|
438
|
+
connect('origin', canonicalOrigin.origin, {
|
|
435
439
|
kind: 'declares-canonical',
|
|
436
440
|
label: 'declares canonical origin',
|
|
437
441
|
sourceLabel: 'HTML canonical metadata',
|
|
@@ -440,12 +444,12 @@ export function buildLookupAssetGraph(input) {
|
|
|
440
444
|
limitations: limitations(record(input.pageIdentity).limitations),
|
|
441
445
|
lenses: ['identity'],
|
|
442
446
|
href: '#evidence-page',
|
|
443
|
-
boundary: trustBoundary(
|
|
447
|
+
boundary: trustBoundary(canonicalOrigin.hostname, finalHost || target, canonicalOrigin.origin, finalOrigin?.origin ?? null),
|
|
444
448
|
}, 'Publisher-declared canonical origin', identitySource);
|
|
445
449
|
}
|
|
446
|
-
const
|
|
447
|
-
if (
|
|
448
|
-
connect('origin',
|
|
450
|
+
const openGraphOrigin = webOrigin(record(input.pageOpenGraphUrl).url);
|
|
451
|
+
if (openGraphOrigin) {
|
|
452
|
+
connect('origin', openGraphOrigin.origin, {
|
|
449
453
|
kind: 'declares-open-graph',
|
|
450
454
|
label: 'declares Open Graph origin',
|
|
451
455
|
sourceLabel: 'Open Graph metadata',
|
|
@@ -454,13 +458,13 @@ export function buildLookupAssetGraph(input) {
|
|
|
454
458
|
limitations: limitations(record(input.pageIdentity).limitations),
|
|
455
459
|
lenses: ['identity'],
|
|
456
460
|
href: '#evidence-page',
|
|
457
|
-
boundary: trustBoundary(
|
|
461
|
+
boundary: trustBoundary(openGraphOrigin.hostname, finalHost || target, openGraphOrigin.origin, finalOrigin?.origin ?? null),
|
|
458
462
|
}, 'Publisher-declared Open Graph origin', identitySource);
|
|
459
463
|
}
|
|
460
464
|
for (const origin of textList(record(input.pageForms).externalActionOrigins)) {
|
|
461
|
-
const
|
|
462
|
-
if (
|
|
463
|
-
connect('origin',
|
|
465
|
+
const destination = webOrigin(origin);
|
|
466
|
+
if (destination) {
|
|
467
|
+
connect('origin', destination.origin, {
|
|
464
468
|
kind: 'form-destination',
|
|
465
469
|
label: 'form may submit to',
|
|
466
470
|
sourceLabel: 'Static HTML form',
|
|
@@ -469,14 +473,14 @@ export function buildLookupAssetGraph(input) {
|
|
|
469
473
|
limitations: ['A declared form action does not prove that a user submitted data or that the endpoint received it.'],
|
|
470
474
|
lenses: ['identity'],
|
|
471
475
|
href: '#evidence-page',
|
|
472
|
-
boundary: trustBoundary(
|
|
476
|
+
boundary: trustBoundary(destination.hostname, finalHost || target, destination.origin, finalOrigin?.origin ?? null),
|
|
473
477
|
}, 'External form-action origin', identitySource);
|
|
474
478
|
}
|
|
475
479
|
}
|
|
476
480
|
for (const origin of textList(record(input.pageResources).externalOrigins)) {
|
|
477
|
-
const
|
|
478
|
-
if (
|
|
479
|
-
connect('
|
|
481
|
+
const resource = webOrigin(origin);
|
|
482
|
+
if (resource) {
|
|
483
|
+
connect('origin', resource.origin, {
|
|
480
484
|
kind: 'loads-from',
|
|
481
485
|
label: 'references resources from',
|
|
482
486
|
sourceLabel: 'Static HTML resources',
|
|
@@ -485,7 +489,7 @@ export function buildLookupAssetGraph(input) {
|
|
|
485
489
|
limitations: ['A static resource reference does not prove that a browser loaded the resource or disclosed data to it.'],
|
|
486
490
|
lenses: ['identity'],
|
|
487
491
|
href: '#evidence-page',
|
|
488
|
-
boundary: trustBoundary(
|
|
492
|
+
boundary: trustBoundary(resource.hostname, finalHost || target, resource.origin, finalOrigin?.origin ?? null),
|
|
489
493
|
}, 'External resource origin', identitySource);
|
|
490
494
|
}
|
|
491
495
|
}
|
|
@@ -507,7 +511,7 @@ export function buildLookupAssetGraph(input) {
|
|
|
507
511
|
const structuredIdentity = record(input.structuredDataIdentity);
|
|
508
512
|
for (const entity of records(structuredIdentity.entities, 12)) {
|
|
509
513
|
const entityLabel = text(entity.name, 180);
|
|
510
|
-
const declaredOrigin =
|
|
514
|
+
const declaredOrigin = webOrigin(entity.declaredOrigin);
|
|
511
515
|
const entityId = entityLabel
|
|
512
516
|
? connect('identity', entityLabel, {
|
|
513
517
|
kind: 'declares-publisher',
|
|
@@ -524,7 +528,7 @@ export function buildLookupAssetGraph(input) {
|
|
|
524
528
|
}, textList(entity.types, 5).join(', ') || 'Publisher-declared entity', identitySource)
|
|
525
529
|
: identitySource;
|
|
526
530
|
if (declaredOrigin) {
|
|
527
|
-
connect('origin', declaredOrigin, {
|
|
531
|
+
connect('origin', declaredOrigin.origin, {
|
|
528
532
|
kind: 'declares-origin',
|
|
529
533
|
label: 'declares origin',
|
|
530
534
|
sourceLabel: 'Structured identity metadata',
|
|
@@ -533,7 +537,7 @@ export function buildLookupAssetGraph(input) {
|
|
|
533
537
|
limitations: limitations(structuredIdentity.limitations),
|
|
534
538
|
lenses: ['identity'],
|
|
535
539
|
href: '#evidence-structured-identity',
|
|
536
|
-
boundary: trustBoundary(declaredOrigin, finalHost || target),
|
|
540
|
+
boundary: trustBoundary(declaredOrigin.hostname, finalHost || target, declaredOrigin.origin, finalOrigin?.origin ?? null),
|
|
537
541
|
}, 'Publisher-declared structured-data origin', entityId || identitySource);
|
|
538
542
|
}
|
|
539
543
|
for (const sameAsHost of textList(entity.sameAsHosts, 12)) {
|
|
@@ -666,7 +670,11 @@ export function buildLookupAssetGraph(input) {
|
|
|
666
670
|
}
|
|
667
671
|
}
|
|
668
672
|
const issuer = record(input.tlsIssuer);
|
|
669
|
-
const
|
|
673
|
+
const issuerOrganizations = textList(issuer.organizations, 8);
|
|
674
|
+
const issuerCommonNames = textList(issuer.commonNames, 8);
|
|
675
|
+
const issuerLabel = issuerOrganizations[0]
|
|
676
|
+
|| issuerCommonNames[0]
|
|
677
|
+
|| text(issuer.organization || issuer.commonName || issuer.CN, 180);
|
|
670
678
|
if (issuerLabel) {
|
|
671
679
|
const issuerId = addNode('issuer', issuerLabel, 'Certificate issuer');
|
|
672
680
|
if (issuerId) {
|
|
@@ -7,8 +7,8 @@ import { normalizeOpaqueReferenceId } from "../cases/opaque-reference-id.mjs";
|
|
|
7
7
|
import { normalizePageBaseline } from "./page-baseline.mjs";
|
|
8
8
|
import { isInformativePerceptualHash as isInformativeFaviconHash } from "../../lib/perceptual-hash-comparison.mjs";
|
|
9
9
|
import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
|
|
10
|
-
import { BRAND_PROFILE_SCHEMA, BRAND_PROFILE_SCHEMA_VERSION, MAX_DESIRED_POSTURE_BASELINES, MAX_DESIRED_POSTURE_CHANGE_WINDOWS, MAX_DESIRED_POSTURE_OBSERVATIONS, MAX_DESIRED_POSTURE_RECORDS, MAX_DESIRED_POSTURE_SUPPRESSIONS, MAX_DKIM_SELECTOR_LENGTH, MAX_DKIM_SELECTORS, MAX_PROFILES, MAX_PROFILE_DOMAIN_LENGTH, MAX_PROFILE_NAME_LENGTH, MAX_PROFILE_STORE_BYTES, MAX_PROFILE_TEXT_LENGTH, MAX_PROFILE_TLD_LENGTH, MAX_PROFILE_VALUE_INPUTS, MAX_PROFILE_VALUES, MAX_PROTECTION_ATTESTATIONS, SUPPORTED_BRAND_PROFILE_SCHEMA_VERSIONS, } from "../contracts/workspace-portability.mjs";
|
|
11
|
-
export { BRAND_PROFILE_SCHEMA, BRAND_PROFILE_SCHEMA_VERSION, MAX_DESIRED_POSTURE_BASELINES, MAX_DESIRED_POSTURE_CHANGE_WINDOWS, MAX_DESIRED_POSTURE_OBSERVATIONS, MAX_DESIRED_POSTURE_RECORDS, MAX_DESIRED_POSTURE_SUPPRESSIONS, MAX_DKIM_SELECTOR_LENGTH, MAX_DKIM_SELECTORS, MAX_PROFILES, MAX_PROFILE_DOMAIN_LENGTH, MAX_PROFILE_NAME_LENGTH, MAX_PROFILE_STORE_BYTES, MAX_PROFILE_TEXT_LENGTH, MAX_PROFILE_TLD_LENGTH, MAX_PROFILE_VALUE_INPUTS, MAX_PROFILE_VALUES, MAX_PROTECTION_ATTESTATIONS, SUPPORTED_BRAND_PROFILE_SCHEMA_VERSIONS, } from "../contracts/workspace-portability.mjs";
|
|
10
|
+
import { BRAND_PROFILE_SCHEMA, BRAND_PROFILE_SCHEMA_VERSION, MAX_DESIRED_POSTURE_BASELINES, MAX_DESIRED_POSTURE_CHANGE_WINDOWS, MAX_DESIRED_POSTURE_OBSERVATIONS, MAX_DESIRED_POSTURE_RECORDS, MAX_DESIRED_POSTURE_SUPPRESSIONS, MAX_DKIM_SELECTOR_LENGTH, MAX_DKIM_SELECTORS, MAX_OFFICIAL_CHANNELS, MAX_PROFILES, MAX_PROFILE_DOMAIN_LENGTH, MAX_PROFILE_NAME_LENGTH, MAX_PROFILE_STORE_BYTES, MAX_PROFILE_TEXT_LENGTH, MAX_PROFILE_TLD_LENGTH, MAX_PROFILE_URL_LENGTH, MAX_PROFILE_VALUE_INPUTS, MAX_PROFILE_VALUES, MAX_PROTECTION_ATTESTATIONS, MAX_RIGHTS_REFERENCES, SUPPORTED_BRAND_PROFILE_SCHEMA_VERSIONS, } from "../contracts/workspace-portability.mjs";
|
|
11
|
+
export { BRAND_PROFILE_SCHEMA, BRAND_PROFILE_SCHEMA_VERSION, MAX_DESIRED_POSTURE_BASELINES, MAX_DESIRED_POSTURE_CHANGE_WINDOWS, MAX_DESIRED_POSTURE_OBSERVATIONS, MAX_DESIRED_POSTURE_RECORDS, MAX_DESIRED_POSTURE_SUPPRESSIONS, MAX_DKIM_SELECTOR_LENGTH, MAX_DKIM_SELECTORS, MAX_OFFICIAL_CHANNELS, MAX_PROFILES, MAX_PROFILE_DOMAIN_LENGTH, MAX_PROFILE_NAME_LENGTH, MAX_PROFILE_STORE_BYTES, MAX_PROFILE_TEXT_LENGTH, MAX_PROFILE_TLD_LENGTH, MAX_PROFILE_URL_LENGTH, MAX_PROFILE_VALUE_INPUTS, MAX_PROFILE_VALUES, MAX_PROTECTION_ATTESTATIONS, MAX_RIGHTS_REFERENCES, SUPPORTED_BRAND_PROFILE_SCHEMA_VERSIONS, } from "../contracts/workspace-portability.mjs";
|
|
12
12
|
const SHA256_RE = /^[a-f0-9]{64}$/i;
|
|
13
13
|
const CONTROL_RE = /[\x00-\x1f\x7f]/;
|
|
14
14
|
const DNS_LABEL_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
@@ -42,6 +42,17 @@ export const DESIRED_POSTURE_SUPPRESSION_FIELDS = Object.freeze([
|
|
|
42
42
|
]);
|
|
43
43
|
const DESIRED_POSTURE_FIELDS = new Set(DESIRED_POSTURE_SUPPRESSION_FIELDS);
|
|
44
44
|
const POSTURE_CHECK_STATUSES = new Set(['danger', 'info', 'pass', 'warning']);
|
|
45
|
+
export const OFFICIAL_CHANNEL_PLATFORMS = [
|
|
46
|
+
'facebook',
|
|
47
|
+
'instagram',
|
|
48
|
+
'linkedin',
|
|
49
|
+
'telegram',
|
|
50
|
+
'tiktok',
|
|
51
|
+
'x',
|
|
52
|
+
'youtube',
|
|
53
|
+
'other',
|
|
54
|
+
];
|
|
55
|
+
export const RIGHTS_REFERENCE_KINDS = ['trademark', 'copyright', 'design', 'other'];
|
|
45
56
|
function record(value) {
|
|
46
57
|
return ordinaryWorkspaceRecord(value, 'Brand Profile input') ?? {};
|
|
47
58
|
}
|
|
@@ -333,6 +344,82 @@ function normalizeFaviconHash(value) {
|
|
|
333
344
|
function normalizeFaviconPHash(value) {
|
|
334
345
|
return typeof value === 'string' && isInformativeFaviconHash(value) ? value.toLowerCase() : '';
|
|
335
346
|
}
|
|
347
|
+
function normalizePublicUrl(value) {
|
|
348
|
+
if (typeof value !== 'string' || CONTROL_RE.test(value) || value.length > MAX_PROFILE_URL_LENGTH * 2)
|
|
349
|
+
return '';
|
|
350
|
+
try {
|
|
351
|
+
const parsed = new URL(value.trim());
|
|
352
|
+
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password)
|
|
353
|
+
return '';
|
|
354
|
+
const normalized = parsed.toString();
|
|
355
|
+
return normalized.length <= MAX_PROFILE_URL_LENGTH ? normalized : '';
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
return '';
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
function normalizeOfficialChannels(value) {
|
|
362
|
+
if (!Array.isArray(value))
|
|
363
|
+
return [];
|
|
364
|
+
const output = new Map();
|
|
365
|
+
for (const candidate of value.slice(0, MAX_OFFICIAL_CHANNELS * 4)) {
|
|
366
|
+
const item = record(candidate);
|
|
367
|
+
const platform = typeof item.platform === 'string'
|
|
368
|
+
&& OFFICIAL_CHANNEL_PLATFORMS.includes(item.platform)
|
|
369
|
+
? item.platform
|
|
370
|
+
: null;
|
|
371
|
+
const url = normalizePublicUrl(item.url);
|
|
372
|
+
if (!platform || !url)
|
|
373
|
+
continue;
|
|
374
|
+
const key = url.toLowerCase();
|
|
375
|
+
if (output.has(key))
|
|
376
|
+
continue;
|
|
377
|
+
output.set(key, {
|
|
378
|
+
platform,
|
|
379
|
+
url,
|
|
380
|
+
handle: boundedText(item.handle),
|
|
381
|
+
role: boundedText(item.role),
|
|
382
|
+
reviewedAt: timestamp(item.reviewedAt, null),
|
|
383
|
+
});
|
|
384
|
+
if (output.size >= MAX_OFFICIAL_CHANNELS)
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
return [...output.values()];
|
|
388
|
+
}
|
|
389
|
+
function normalizeRightsReferences(value) {
|
|
390
|
+
if (!Array.isArray(value))
|
|
391
|
+
return [];
|
|
392
|
+
const output = new Map();
|
|
393
|
+
for (const candidate of value.slice(0, MAX_RIGHTS_REFERENCES * 4)) {
|
|
394
|
+
const item = record(candidate);
|
|
395
|
+
const kind = typeof item.kind === 'string' && RIGHTS_REFERENCE_KINDS.includes(item.kind)
|
|
396
|
+
? item.kind
|
|
397
|
+
: null;
|
|
398
|
+
const owner = boundedText(item.owner);
|
|
399
|
+
const identifier = boundedText(item.identifier);
|
|
400
|
+
if (!kind || (!owner && !identifier))
|
|
401
|
+
continue;
|
|
402
|
+
const jurisdiction = boundedText(item.jurisdiction);
|
|
403
|
+
const sourceUrl = item.sourceUrl == null || item.sourceUrl === '' ? '' : normalizePublicUrl(item.sourceUrl);
|
|
404
|
+
if (item.sourceUrl && !sourceUrl)
|
|
405
|
+
continue;
|
|
406
|
+
const key = `${kind}\u0000${owner.toLowerCase()}\u0000${identifier.toLowerCase()}\u0000${jurisdiction.toLowerCase()}`;
|
|
407
|
+
if (output.has(key))
|
|
408
|
+
continue;
|
|
409
|
+
output.set(key, {
|
|
410
|
+
kind,
|
|
411
|
+
owner,
|
|
412
|
+
identifier,
|
|
413
|
+
jurisdiction,
|
|
414
|
+
sourceUrl,
|
|
415
|
+
reviewedAt: timestamp(item.reviewedAt, null),
|
|
416
|
+
note: boundedText(item.note),
|
|
417
|
+
});
|
|
418
|
+
if (output.size >= MAX_RIGHTS_REFERENCES)
|
|
419
|
+
break;
|
|
420
|
+
}
|
|
421
|
+
return [...output.values()];
|
|
422
|
+
}
|
|
336
423
|
/** Normalize one profile while retaining only known, bounded fields. */
|
|
337
424
|
export function normalizeBrandProfile(raw, options = {}) {
|
|
338
425
|
const value = record(raw);
|
|
@@ -360,6 +447,7 @@ export function normalizeBrandProfile(raw, options = {}) {
|
|
|
360
447
|
id: profileId,
|
|
361
448
|
name,
|
|
362
449
|
officialDomains,
|
|
450
|
+
officialChannels: normalizeOfficialChannels(value.officialChannels),
|
|
363
451
|
productNames: normalizeProfileTextValues(value.productNames),
|
|
364
452
|
tlds: normalizeProfileTlds(value.tlds),
|
|
365
453
|
approvedPartnerDomains: normalizeProfileDomains(value.approvedPartnerDomains),
|
|
@@ -373,6 +461,7 @@ export function normalizeBrandProfile(raw, options = {}) {
|
|
|
373
461
|
desiredPostureBaselines: normalizeDesiredPostureBaselines(value.desiredPostureBaselines, officialDomains, now, profileId),
|
|
374
462
|
trademarkOwner: boundedText(value.trademarkOwner),
|
|
375
463
|
trademarkRegistration: boundedText(value.trademarkRegistration),
|
|
464
|
+
rightsReferences: normalizeRightsReferences(value.rightsReferences),
|
|
376
465
|
officialFaviconHash: baselineDomainMismatch ? '' : normalizeFaviconHash(value.officialFaviconHash),
|
|
377
466
|
officialFaviconPHash: baselineDomainMismatch ? '' : normalizeFaviconPHash(value.officialFaviconPHash),
|
|
378
467
|
pageBaseline,
|
|
@@ -380,6 +469,33 @@ export function normalizeBrandProfile(raw, options = {}) {
|
|
|
380
469
|
updatedAt: options.touch === true ? now : timestamp(value.updatedAt, createdAt),
|
|
381
470
|
};
|
|
382
471
|
}
|
|
472
|
+
const BRAND_PROFILE_FIELD_PATCH_KEYS = Object.freeze([
|
|
473
|
+
'allowlistedDomains',
|
|
474
|
+
'allowlistedRegistrars',
|
|
475
|
+
'desiredPostureBaselines',
|
|
476
|
+
'protectionAttestations',
|
|
477
|
+
]);
|
|
478
|
+
/**
|
|
479
|
+
* Applies one field-owned edit to the transaction-current profile. Identity,
|
|
480
|
+
* timestamps, and every field outside the explicit patch remain owned by that
|
|
481
|
+
* current record rather than by the caller's potentially stale snapshot.
|
|
482
|
+
*/
|
|
483
|
+
export function applyBrandProfileFieldPatch(existing, patch, options = {}) {
|
|
484
|
+
const merged = { ...existing };
|
|
485
|
+
const supplied = patch;
|
|
486
|
+
for (const key of BRAND_PROFILE_FIELD_PATCH_KEYS) {
|
|
487
|
+
if (Object.hasOwn(supplied, key))
|
|
488
|
+
merged[key] = supplied[key];
|
|
489
|
+
}
|
|
490
|
+
const normalized = normalizeBrandProfile(merged, {
|
|
491
|
+
existing,
|
|
492
|
+
nowIso: options.nowIso,
|
|
493
|
+
touch: true,
|
|
494
|
+
});
|
|
495
|
+
if (!normalized)
|
|
496
|
+
throw new Error('The Brand Profile field update is invalid.');
|
|
497
|
+
return normalized;
|
|
498
|
+
}
|
|
383
499
|
function profileList(raw) {
|
|
384
500
|
if (Array.isArray(raw))
|
|
385
501
|
return raw;
|
|
@@ -1,21 +1,27 @@
|
|
|
1
1
|
import { normalizeDomain } from "../cases/case-model.mjs";
|
|
2
|
+
import { CASE_DISPOSITIONS as CASE_DISPOSITION_OPTIONS } from "../cases/case-record-contracts.mjs";
|
|
2
3
|
import { normalizeBulkPresentationSortKey } from "./bulk-sort.mjs";
|
|
3
4
|
import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
|
|
4
5
|
import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
|
|
5
6
|
import { BULK_REVIEW_SCHEMA, BULK_REVIEW_SCHEMA_VERSION, MAX_BULK_REVIEW_NAME_LENGTH, MAX_BULK_REVIEW_PRESETS, MAX_BULK_REVIEW_ROWS, MAX_BULK_REVIEW_STORE_BYTES, } from "../contracts/workspace-portability.mjs";
|
|
6
7
|
export { BULK_REVIEW_SCHEMA, BULK_REVIEW_SCHEMA_VERSION, MAX_BULK_REVIEW_NAME_LENGTH, MAX_BULK_REVIEW_PRESETS, MAX_BULK_REVIEW_ROWS, MAX_BULK_REVIEW_STORE_BYTES, } from "../contracts/workspace-portability.mjs";
|
|
7
8
|
export const BULK_REVIEW_STATES = ['unreviewed', 'reviewing', 'reviewed', 'deferred'];
|
|
9
|
+
export const BULK_SOURCE_FILTERS = ['', 'complete', 'limited', 'unrecorded'];
|
|
10
|
+
export const BULK_LIFECYCLE_FILTERS = ['', 'registered', 'for_sale', 'expiring', 'available', 'unknown', 'error'];
|
|
11
|
+
export const BULK_AGE_FILTERS = ['', 'new_30', 'new_365', 'older_365', 'unknown'];
|
|
12
|
+
export const BULK_MAIL_FILTERS = ['', 'mail', 'no_mail', 'authenticated', 'auth_gap', 'unknown'];
|
|
13
|
+
export const BULK_GROUP_OPTIONS = ['', 'mutation', 'tld', 'registrar', 'nameserver'];
|
|
8
14
|
const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
|
|
9
15
|
const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,128}$/u;
|
|
10
16
|
const PRIMARY_FILTERS = new Set(['all', 'available', 'registered', 'high_risk', 'trusted', 'profile_unevaluated', 'errors']);
|
|
11
|
-
const SOURCE_FILTERS = new Set(
|
|
12
|
-
const LIFECYCLE_FILTERS = new Set(
|
|
13
|
-
const AGE_FILTERS = new Set(
|
|
14
|
-
const MAIL_FILTERS = new Set(
|
|
15
|
-
const GROUPS = new Set(
|
|
16
|
-
const SORT_KEYS = new Set(['domain', 'availability', 'risk', 'opportunity', 'activity', 'registrar', 'mutation']);
|
|
17
|
+
const SOURCE_FILTERS = new Set(BULK_SOURCE_FILTERS);
|
|
18
|
+
const LIFECYCLE_FILTERS = new Set(BULK_LIFECYCLE_FILTERS);
|
|
19
|
+
const AGE_FILTERS = new Set(BULK_AGE_FILTERS);
|
|
20
|
+
const MAIL_FILTERS = new Set(BULK_MAIL_FILTERS);
|
|
21
|
+
const GROUPS = new Set(BULK_GROUP_OPTIONS);
|
|
22
|
+
const SORT_KEYS = new Set(['domain', 'availability', 'confidence', 'risk', 'opportunity', 'activity', 'registrar', 'mutation']);
|
|
17
23
|
const REVIEW_STATES = new Set(BULK_REVIEW_STATES);
|
|
18
|
-
const CASE_DISPOSITIONS = new Set(['', 'untracked',
|
|
24
|
+
const CASE_DISPOSITIONS = new Set(['', 'untracked', ...CASE_DISPOSITION_OPTIONS.map((item) => item.value)]);
|
|
19
25
|
const SIGNAL_FILTERS = new Set(['favicon', 'password', 'phishing', 'asset_reuse', 'idn']);
|
|
20
26
|
function record(value) {
|
|
21
27
|
return ordinaryWorkspaceRecord(value, 'Bulk-review input') ?? {};
|
|
@@ -488,10 +488,11 @@ export function deleteBulkSession(raw, idRaw) {
|
|
|
488
488
|
function sourceStateMap(value) {
|
|
489
489
|
return new Map(value.sourceCoverage.map((item) => [item.source, item.state]));
|
|
490
490
|
}
|
|
491
|
-
function
|
|
491
|
+
export function areBulkRiskScoresComparable(previous, current) {
|
|
492
492
|
return previous.profileContext.sourceState === 'ready'
|
|
493
493
|
&& current.profileContext.sourceState === 'ready'
|
|
494
494
|
&& sameProfileContext(previous.profileContext, current.profileContext)
|
|
495
|
+
&& previous.scanDepth === current.scanDepth
|
|
495
496
|
&& previous.risk !== null
|
|
496
497
|
&& current.risk !== null
|
|
497
498
|
&& previous.riskModelVersion !== null
|
|
@@ -501,7 +502,7 @@ function resultChanges(previous, current) {
|
|
|
501
502
|
const changes = [];
|
|
502
503
|
if (previous.availability !== current.availability)
|
|
503
504
|
changes.push(`Registration: ${previous.availability} → ${current.availability}`);
|
|
504
|
-
if (
|
|
505
|
+
if (areBulkRiskScoresComparable(previous, current) && previous.risk !== current.risk) {
|
|
505
506
|
changes.push(`Risk: ${previous.risk ?? 'unavailable'} → ${current.risk ?? 'unavailable'}`);
|
|
506
507
|
}
|
|
507
508
|
if (previous.registrar !== current.registrar)
|
|
@@ -554,7 +555,7 @@ export function compareBulkSessions(baselineRaw, currentRaw) {
|
|
|
554
555
|
rows.push({ domain, changes: ['Absent from the later settled result set.'] });
|
|
555
556
|
}
|
|
556
557
|
else {
|
|
557
|
-
if (!
|
|
558
|
+
if (!areBulkRiskScoresComparable(previous, next))
|
|
558
559
|
riskIncomparable = true;
|
|
559
560
|
const changes = resultChanges(previous, next);
|
|
560
561
|
if (changes.length)
|
|
@@ -576,7 +577,7 @@ export function compareBulkSessions(baselineRaw, currentRaw) {
|
|
|
576
577
|
'A source-state change may reflect collection availability rather than a change to the domain.',
|
|
577
578
|
'A missing row means it was not completed in that saved session; it does not establish domain removal.',
|
|
578
579
|
...(riskIncomparable
|
|
579
|
-
? ['Risk deltas are omitted unless both rows retain the same ready Brand Profile provenance
|
|
580
|
+
? ['Risk deltas are omitted unless both rows retain the same ready Brand Profile provenance, scan depth, and versioned Risk model.']
|
|
580
581
|
: []),
|
|
581
582
|
],
|
|
582
583
|
};
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// small structured language: field names and operators come from allowlists,
|
|
3
3
|
// so imported rules cannot execute code or reach outside bounded case evidence.
|
|
4
4
|
import { latestCaseEvidence } from "../cases/case-model.mjs";
|
|
5
|
+
import { CASE_DISPOSITIONS, CASE_STATUSES } from "../cases/case-record-contracts.mjs";
|
|
5
6
|
import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
|
|
6
7
|
import { DETECTION_RULE_SCHEMA, DETECTION_RULE_SCHEMA_VERSION, MAX_CONDITION_VALUE_LENGTH, MAX_CUSTOM_RISK_TOTAL, MAX_DETECTION_RULES, MAX_RULE_CONDITIONS, MAX_RULE_INPUT_RECORDS, MAX_RULE_NAME_LENGTH, MAX_RULE_RISK_DELTA, MAX_RULE_STORE_BYTES, MAX_RULE_TAG_LENGTH, } from "../contracts/workspace-portability.mjs";
|
|
7
8
|
export { DETECTION_RULE_SCHEMA, DETECTION_RULE_SCHEMA_VERSION, MAX_CONDITION_VALUE_LENGTH, MAX_CUSTOM_RISK_TOTAL, MAX_DETECTION_RULES, MAX_RULE_CONDITIONS, MAX_RULE_IMPORT_BYTES, MAX_RULE_INPUT_RECORDS, MAX_RULE_NAME_LENGTH, MAX_RULE_RISK_DELTA, MAX_RULE_STORE_BYTES, MAX_RULE_TAG_LENGTH, } from "../contracts/workspace-portability.mjs";
|
|
@@ -26,8 +27,8 @@ export const RULE_FIELD_DEFINITIONS = Object.freeze([
|
|
|
26
27
|
{ value: 'mutationTypes', label: 'Mutation type', kind: 'list' },
|
|
27
28
|
{ value: 'nameservers', label: 'Nameserver', kind: 'list' },
|
|
28
29
|
{ value: 'httpSecurityHeaders', label: 'HTTP security header', kind: 'list' },
|
|
29
|
-
{ value: 'status', label: 'Case status', kind: 'enum', values:
|
|
30
|
-
{ value: 'disposition', label: 'Case disposition', kind: 'enum', values:
|
|
30
|
+
{ value: 'status', label: 'Case status', kind: 'enum', values: CASE_STATUSES.map((item) => item.value) },
|
|
31
|
+
{ value: 'disposition', label: 'Case disposition', kind: 'enum', values: CASE_DISPOSITIONS.map((item) => item.value) },
|
|
31
32
|
{ value: 'tags', label: 'Case tag', kind: 'list' },
|
|
32
33
|
]);
|
|
33
34
|
const FIELD_BY_VALUE = new Map(RULE_FIELD_DEFINITIONS.map((field) => [field.value, field]));
|
|
@@ -103,13 +104,14 @@ export function normalizeDetectionRule(raw, { generateId = false } = {}) {
|
|
|
103
104
|
return null;
|
|
104
105
|
const conditions = [];
|
|
105
106
|
const rawConditions = Array.isArray(item.conditions) ? item.conditions : [];
|
|
106
|
-
|
|
107
|
-
const condition = normalizeRuleCondition(item);
|
|
108
|
-
if (condition)
|
|
109
|
-
conditions.push(condition);
|
|
110
|
-
}
|
|
111
|
-
if (!conditions.length)
|
|
107
|
+
if (!rawConditions.length || rawConditions.length > MAX_RULE_CONDITIONS)
|
|
112
108
|
return null;
|
|
109
|
+
for (const rawCondition of rawConditions) {
|
|
110
|
+
const condition = normalizeRuleCondition(rawCondition);
|
|
111
|
+
if (!condition)
|
|
112
|
+
return null;
|
|
113
|
+
conditions.push(condition);
|
|
114
|
+
}
|
|
113
115
|
const riskDelta = normalizeInteger(item.riskDelta, 0, MAX_RULE_RISK_DELTA);
|
|
114
116
|
return {
|
|
115
117
|
id: safeId(item.id) || (generateId ? makeId() : null),
|
|
@@ -121,6 +123,30 @@ export function normalizeDetectionRule(raw, { generateId = false } = {}) {
|
|
|
121
123
|
tag: normalizedText(item.tag, MAX_RULE_TAG_LENGTH).toLowerCase(),
|
|
122
124
|
};
|
|
123
125
|
}
|
|
126
|
+
function validateDetectionRule(raw, { generateId = false } = {}) {
|
|
127
|
+
const item = record(raw);
|
|
128
|
+
if (!Array.isArray(item.conditions) || !item.conditions.length) {
|
|
129
|
+
throw new Error('A custom rule needs at least one condition.');
|
|
130
|
+
}
|
|
131
|
+
if (item.conditions.length > MAX_RULE_CONDITIONS) {
|
|
132
|
+
throw new Error(`Custom rules are limited to ${MAX_RULE_CONDITIONS} conditions.`);
|
|
133
|
+
}
|
|
134
|
+
for (const [index, condition] of item.conditions.entries()) {
|
|
135
|
+
if (!normalizeRuleCondition(condition)) {
|
|
136
|
+
throw new Error(`Condition ${index + 1} is invalid or outside the allowed bounds.`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (item.match !== undefined && item.match !== 'all' && item.match !== 'any') {
|
|
140
|
+
throw new Error('Choose whether all or any conditions must match.');
|
|
141
|
+
}
|
|
142
|
+
if (item.riskDelta !== undefined && normalizeInteger(item.riskDelta, 0, MAX_RULE_RISK_DELTA) === null) {
|
|
143
|
+
throw new Error(`Custom contribution must be a whole number from 0 to ${MAX_RULE_RISK_DELTA}.`);
|
|
144
|
+
}
|
|
145
|
+
const normalized = normalizeDetectionRule(item, { generateId });
|
|
146
|
+
if (!normalized)
|
|
147
|
+
throw new Error('Enter a rule name and valid conditions.');
|
|
148
|
+
return normalized;
|
|
149
|
+
}
|
|
124
150
|
function ruleList(raw) {
|
|
125
151
|
if (Array.isArray(raw))
|
|
126
152
|
return raw;
|
|
@@ -133,23 +159,32 @@ export function detectionRuleStoreVersion(raw) {
|
|
|
133
159
|
const item = record(raw);
|
|
134
160
|
return typeof item.version === 'number' && Number.isFinite(item.version) ? item.version : null;
|
|
135
161
|
}
|
|
136
|
-
export function
|
|
162
|
+
export function recoverDetectionRuleStore(raw) {
|
|
137
163
|
assertWorkspaceInputGraph(raw, 'Detection-rule store');
|
|
138
164
|
assertWorkspaceDeclaredVersion(raw, 'Detection-rule store');
|
|
139
165
|
const byId = new Map();
|
|
140
|
-
|
|
166
|
+
const source = ruleList(raw);
|
|
167
|
+
let rejected = Math.max(0, source.length - MAX_RULE_INPUT_RECORDS);
|
|
168
|
+
for (const item of source.slice(0, MAX_RULE_INPUT_RECORDS)) {
|
|
141
169
|
const rule = normalizeDetectionRule(item);
|
|
142
|
-
if (!rule?.id || byId.has(rule.id))
|
|
170
|
+
if (!rule?.id || byId.has(rule.id)) {
|
|
171
|
+
rejected += 1;
|
|
143
172
|
continue;
|
|
173
|
+
}
|
|
174
|
+
if (byId.size >= MAX_DETECTION_RULES) {
|
|
175
|
+
rejected += 1;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
144
178
|
byId.set(rule.id, { ...rule, id: rule.id });
|
|
145
|
-
if (byId.size >= MAX_DETECTION_RULES)
|
|
146
|
-
break;
|
|
147
179
|
}
|
|
148
|
-
return { version: DETECTION_RULE_SCHEMA_VERSION, rules: [...byId.values()] };
|
|
180
|
+
return { store: { version: DETECTION_RULE_SCHEMA_VERSION, rules: [...byId.values()] }, rejected };
|
|
181
|
+
}
|
|
182
|
+
export function normalizeDetectionRuleStore(raw) {
|
|
183
|
+
return recoverDetectionRuleStore(raw).store;
|
|
149
184
|
}
|
|
150
185
|
export function createDetectionRule(rules, input) {
|
|
151
|
-
const normalized =
|
|
152
|
-
if (!normalized
|
|
186
|
+
const normalized = validateDetectionRule(input, { generateId: true });
|
|
187
|
+
if (!normalized.id)
|
|
153
188
|
throw new Error('Enter a rule name and one valid condition.');
|
|
154
189
|
const existing = normalizeDetectionRuleStore(rules).rules;
|
|
155
190
|
if (existing.length >= MAX_DETECTION_RULES)
|
|
@@ -162,8 +197,8 @@ export function updateDetectionRule(rules, id, patch) {
|
|
|
162
197
|
const current = normalizedRules.find((rule) => rule.id === id);
|
|
163
198
|
if (!current)
|
|
164
199
|
throw new Error('That custom rule no longer exists.');
|
|
165
|
-
const updated =
|
|
166
|
-
if (!updated
|
|
200
|
+
const updated = validateDetectionRule({ ...current, ...record(patch), id });
|
|
201
|
+
if (!updated.id)
|
|
167
202
|
throw new Error('A custom rule needs a name and at least one valid condition.');
|
|
168
203
|
return normalizeDetectionRuleStore(normalizedRules.map((rule) => rule.id === id ? updated : rule)).rules;
|
|
169
204
|
}
|
|
@@ -245,8 +280,14 @@ function ruleSignature(rule) {
|
|
|
245
280
|
});
|
|
246
281
|
}
|
|
247
282
|
export function previewDetectionRule(records, existingRules, rawCandidate) {
|
|
248
|
-
|
|
249
|
-
|
|
283
|
+
let normalized;
|
|
284
|
+
try {
|
|
285
|
+
normalized = validateDetectionRule({ ...record(rawCandidate), id: 'preview-rule', enabled: true });
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
if (!normalized.id)
|
|
250
291
|
return null;
|
|
251
292
|
const candidate = { ...normalized, id: normalized.id };
|
|
252
293
|
const sourceRecords = Array.isArray(records) ? records.slice(0, 500) : [];
|
|
@@ -295,8 +336,15 @@ export function mergeDetectionRules(localRaw, importedRaw) {
|
|
|
295
336
|
let updated = 0;
|
|
296
337
|
let skipped = Math.max(0, importedList.length - MAX_RULE_INPUT_RECORDS);
|
|
297
338
|
for (const item of importedList.slice(0, MAX_RULE_INPUT_RECORDS)) {
|
|
298
|
-
|
|
299
|
-
|
|
339
|
+
let rule;
|
|
340
|
+
try {
|
|
341
|
+
rule = validateDetectionRule(item);
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
skipped++;
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
if (!rule.id) {
|
|
300
348
|
skipped++;
|
|
301
349
|
continue;
|
|
302
350
|
}
|
|
@@ -283,8 +283,11 @@ export function createRelationshipObservation(raw, options = {}) {
|
|
|
283
283
|
throw new Error('That relationship does not contain a supported bounded value and at least one valid domain.');
|
|
284
284
|
}
|
|
285
285
|
const now = new Date().toISOString();
|
|
286
|
-
const observedAt = timestamp(options.observedAt)
|
|
286
|
+
const observedAt = Object.hasOwn(options, 'observedAt') ? timestamp(options.observedAt) : now;
|
|
287
287
|
const retainedAt = timestamp(options.retainedAt) || now;
|
|
288
|
+
if (!observedAt) {
|
|
289
|
+
throw new Error('A relationship observation needs the contributing scan observation time; an unknown time is not replaced with the retention time.');
|
|
290
|
+
}
|
|
288
291
|
const canonical = canonicalIdentity(type, normalizedValue, domains);
|
|
289
292
|
const metadata = TYPE_METADATA[type];
|
|
290
293
|
return {
|