Assessor_2.0/src/profiles/public-search/search.ts

104 lines
8.4 KiB
TypeScript

import type { FindingRecord } from '../../domain/finding';
import { toolIdForRecord } from '../../importers/status-normalization';
import { policyValueName } from '../group-policy/presentation';
import { policyPublicSearchDetails } from '../group-policy/web-search';
import catalog from './catalog.json';
import { publicSecurityTopics } from './public-security-topics';
export interface PublicSearch { query: string; url: string; specificity: 'setting' | 'rule' | 'topic' }
const tools: Record<string, string> = { 'group-policy': 'Microsoft Group Policy', 'cis-cat': 'CIS-CAT / CIS Benchmarks',
maester: 'Maester', pingcastle: 'PingCastle', 'purple-knight': 'Semperis Purple Knight', generic: 'Security assessment' };
const nativeSettings: Record<string, string> = {
MinimumPasswordAge: 'Password policy: minimum age', MaximumPasswordAge: 'Password policy: maximum age',
MinimumPasswordLength: 'Password policy: minimum length', PasswordComplexity: 'Password complexity requirements',
PasswordHistorySize: 'Password reuse / history', ClearTextPassword: 'Reversible password encryption',
RequireLogonToChangePassword: 'Legacy password-change logon requirement',
LockoutBadCount: 'Account lockout threshold', LockoutDuration: 'Account lockout duration', ResetLockoutCount: 'Account lockout counter reset',
};
function field(record: FindingRecord, labels: string[]): string {
const values = record.fields.filter(f => labels.includes(f.label.trim().toLowerCase()));
return values.length === 1 && values[0].value.length <= 16000 ? values[0].value.trim() : '';
}
const fold = (value: string) => value.trim().toLowerCase();
const normalizePath = (value: string) => fold(value).replace(/\//g, '\\')
.replace(/^(?:hkey_local_machine|hkey_current_user|hklm|hkcu|machine|user)\\/, '');
function settingContext(record: FindingRecord): { text: string; scalar: boolean } | undefined {
const title = record.title.length <= 1000 ? record.title.trim() : '';
const explicitName = field(record, ['value name']);
const name = explicitName || policyValueName(field(record, ['configured value'])) || title.split('·').pop()?.trim() || '';
const path = field(record, ['registry path']);
const target = catalog.registryTargets.find(p => normalizePath(p) === normalizePath(`${path}\\${name}`));
if (target) return { text: `Setting: ${target.split('\\').pop()}. Public registry target: ${target}. Identity reference: ${catalog.sources.registry}. This reference identifies the setting; its Azure benchmark is not the selected SCT baseline.`, scalar: true };
const native = /^(system access|privilege rights)\s*[·:]\s*([a-z]+)$/i.exec(title);
const section = fold(field(record, ['policy section'])).replace(/[\s_-]/g, '');
const key = native?.[2] ?? title;
const privilege = catalog.privilegeIds.find(id => fold(id) === fold(key));
if (privilege && (native?.[1].toLowerCase() === 'privilege rights' || section === 'privilegerights')) {
return { text: `Setting: ${privilege}. Policy section: Privilege Rights (User Rights Assignment). Configuration: Computer Configuration > Windows Settings > Security Settings > Local Policies > User Rights Assignment. Reference: ${catalog.sources.privileges}.`, scalar: false };
}
const nativeKey = Object.keys(nativeSettings).find(id => fold(id) === fold(key));
if (nativeKey && (native?.[1].toLowerCase() === 'system access' || section === 'systemaccess')) {
const reference = /lockout/i.test(nativeKey) ? catalog.sources.registry : catalog.sources.passwords;
return { text: `Setting: ${nativeKey}. Subject: ${nativeSettings[nativeKey]}. Policy section: System Access. Configuration: Computer Configuration > Windows Settings > Security Settings > Account Policies. Reference: ${reference}.`, scalar: true };
}
const asr = catalog.asrIds.find(id => fold(id) === fold(name));
if (asr && /\\windows defender(?:\\|$)/i.test(path) && /\\asr\\rules$/i.test(path)) {
return { text: `Setting: Microsoft Defender Attack Surface Reduction rule ${asr}. Reference: ${catalog.sources.asr}. Explain the exact rule name and the Disabled, Block, Audit and Warn modes.`, scalar: true };
}
return undefined;
}
function selectedTarget(record: FindingRecord): string {
const raw = field(record, ['baseline value']);
if (!raw || raw.length > 160) return '';
let value: unknown = raw;
try { value = JSON.parse(raw); } catch { /* Literal scalar remains eligible. */ }
if (typeof value === 'object' && value && !Array.isArray(value)) {
const object = value as Record<string, unknown>;
if (Object.keys(object).every(k => ['kind', 'value'].includes(k)) && ['number', 'integer', 'uint32', 'boolean'].includes(String(object.kind))) value = object.value;
}
if (typeof value === 'boolean') return `Selected benchmark target: ${value}.`;
if (typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 65535) return `Selected benchmark target: ${value}. Verify this target against the selected baseline version; do not assume it applies to every device.`;
return '';
}
/** Only catalog-owned identifiers are returned, including for dynamic test instances. */
function ruleContext(record: FindingRecord, tool: string): string | undefined {
const ids = tool === 'maester' ? catalog.maesterIds : tool === 'pingcastle' ? catalog.pingcastleIds : [];
const raw = field(record, ['rule id', 'rule-id', 'test id', 'test-id', 'id']);
const exact = ids.find(id => fold(id) === fold(raw) || fold(id) === fold(record.title));
const candidates = exact ? [exact] : ids.filter(id => new RegExp(`^${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:\\s*[-:·]|\\.[0-9]+(?:[: .]|$))`, 'i').test(record.title));
if (candidates.length !== 1) return undefined;
return `Public rule ID: ${candidates[0]}. Rule catalog: ${tool === 'maester' ? catalog.sources.maester : catalog.sources.pingcastle}. Retrieve this exact rule, its rationale and remediation; do not substitute another rule.`;
}
export function findingWebSearch(record: FindingRecord, toolId?: string): PublicSearch {
const tool = toolIdForRecord(record, toolId) ?? 'generic';
const toolName = Object.hasOwn(tools, tool) ? tools[tool] : tools.generic;
const setting = settingContext(record);
const rule = ruleContext(record, tool);
const policy = tool === 'group-policy' ? policyPublicSearchDetails(record) : undefined;
const evidence = [record.title, field(record, ['description']), field(record, ['rationale'])].join('\n').slice(0, 20000);
const subjects = publicSecurityTopics(evidence);
const scenario = ['Fail', 'Failed', 'Pass', 'Passed', 'Not configured', 'Conflicts', 'Duplicates'].find(s => fold(s) === fold(field(record, ['status'])));
const productSource = field(record, ['baseline', 'benchmark', 'benchmark title', 'benchmark name']);
const products = ['Windows Server 2016', 'Windows Server 2019', 'Windows Server 2022', 'Windows Server 2025', 'Windows 10', 'Windows 11', 'Microsoft 365', 'Microsoft Edge', 'Office 2016', 'Ubuntu', 'Debian', 'Red Hat Enterprise Linux']
.filter(name => new RegExp(`\\b${name}\\b`, 'i').test(productSource));
const specificity = setting || policy?.exact ? 'setting' : rule ? 'rule' : 'topic';
const scope = ['computer', 'user'].find(value => value === fold(field(record, ['scope'])));
const text = [
`Explain this security check from ${toolName}.`, setting?.text, rule,
products.length && `Benchmark product family (not proof of device applicability): ${products.join(', ')}.`,
setting?.scalar && selectedTarget(record),
subjects.length && `Public technical topics: ${subjects.join('; ')}.`,
scenario && `Comparison scenario: ${scenario}.`,
!setting && policy && `Subject: ${policy.subject}.`, policy?.context,
scope && `Scope: ${scope}.`,
specificity === 'topic' ? 'The exact public rule is not resolved; discuss only the named topics and do not invent a rule ID or benchmark value.' : '',
'Answer in German. Focus on this exact setting or rule, not a general product overview. Explain its purpose, security impact, required versus optional configuration, precise remediation steps and how to verify the change. Cite the original vendor or benchmark publisher with direct links. Separate recommendation, default and effective configuration. Do not assume a server role, operating system version or scope that is not supplied. No customer configuration or identities are supplied.'
].filter(Boolean).join(' ');
return { query: text, url: `https://www.google.com/search?udm=50&q=${encodeURIComponent(text)}`, specificity };
}