securl 1.4.1
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/CHANGELOG.md +241 -0
- package/LICENSE +21 -0
- package/README.md +427 -0
- package/RELEASING.md +37 -0
- package/SECURITY.md +27 -0
- package/dist/certificate.d.ts +5 -0
- package/dist/certificate.js +92 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +674 -0
- package/dist/compromiseSignals.d.ts +10 -0
- package/dist/compromiseSignals.js +183 -0
- package/dist/cookie-analysis.d.ts +2 -0
- package/dist/cookie-analysis.js +41 -0
- package/dist/cookieAnalysis.d.ts +2 -0
- package/dist/cookieAnalysis.js +82 -0
- package/dist/ctDiscovery.d.ts +19 -0
- package/dist/ctDiscovery.js +357 -0
- package/dist/domain-security.d.ts +10 -0
- package/dist/domain-security.js +416 -0
- package/dist/header-analysis.d.ts +14 -0
- package/dist/header-analysis.js +165 -0
- package/dist/historyDiff.d.ts +4 -0
- package/dist/historyDiff.js +117 -0
- package/dist/html-extraction.d.ts +12 -0
- package/dist/html-extraction.js +279 -0
- package/dist/html-page-analysis.d.ts +38 -0
- package/dist/html-page-analysis.js +459 -0
- package/dist/htmlInsights.d.ts +23 -0
- package/dist/htmlInsights.js +460 -0
- package/dist/identityProvider.d.ts +14 -0
- package/dist/identityProvider.js +259 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +1008 -0
- package/dist/infrastructure.d.ts +9 -0
- package/dist/infrastructure.js +149 -0
- package/dist/libraryRisk.d.ts +3 -0
- package/dist/libraryRisk.js +164 -0
- package/dist/network-validation.d.ts +30 -0
- package/dist/network-validation.js +161 -0
- package/dist/network.d.ts +34 -0
- package/dist/network.js +139 -0
- package/dist/passive-intelligence.d.ts +21 -0
- package/dist/passive-intelligence.js +247 -0
- package/dist/path-discovery.d.ts +4 -0
- package/dist/path-discovery.js +50 -0
- package/dist/postureDigest.d.ts +142 -0
- package/dist/postureDigest.js +159 -0
- package/dist/postureDrift.d.ts +4 -0
- package/dist/postureDrift.js +118 -0
- package/dist/postureRemediation.d.ts +6 -0
- package/dist/postureRemediation.js +286 -0
- package/dist/redirectChain.d.ts +2 -0
- package/dist/redirectChain.js +39 -0
- package/dist/riskEvents.d.ts +3 -0
- package/dist/riskEvents.js +187 -0
- package/dist/scannerConfig.d.ts +49 -0
- package/dist/scannerConfig.js +79 -0
- package/dist/scoring.d.ts +32 -0
- package/dist/scoring.js +367 -0
- package/dist/security-txt.d.ts +4 -0
- package/dist/security-txt.js +123 -0
- package/dist/surfaceEnrichment.d.ts +44 -0
- package/dist/surfaceEnrichment.js +377 -0
- package/dist/technology-detection.d.ts +4 -0
- package/dist/technology-detection.js +93 -0
- package/dist/types.d.ts +730 -0
- package/dist/types.js +1 -0
- package/dist/utils.d.ts +7 -0
- package/dist/utils.js +66 -0
- package/dist/wafFingerprint.d.ts +5 -0
- package/dist/wafFingerprint.js +156 -0
- package/examples/risk-events.mjs +27 -0
- package/examples/scan-url.mjs +17 -0
- package/package.json +102 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
const SEVERITY_ORDER = {
|
|
2
|
+
critical: 0,
|
|
3
|
+
warning: 1,
|
|
4
|
+
info: 2,
|
|
5
|
+
};
|
|
6
|
+
const normalizeArray = (value) => (Array.isArray(value) ? value : []);
|
|
7
|
+
const countIssuesBySeverity = (issues) => ({
|
|
8
|
+
critical: issues.filter((issue) => issue.severity === "critical").length,
|
|
9
|
+
warning: issues.filter((issue) => issue.severity === "warning").length,
|
|
10
|
+
info: issues.filter((issue) => issue.severity === "info").length,
|
|
11
|
+
});
|
|
12
|
+
const topIssues = (issues, limit) => [...issues]
|
|
13
|
+
.sort((left, right) => {
|
|
14
|
+
const severityDelta = SEVERITY_ORDER[left.severity] - SEVERITY_ORDER[right.severity];
|
|
15
|
+
if (severityDelta !== 0) {
|
|
16
|
+
return severityDelta;
|
|
17
|
+
}
|
|
18
|
+
return left.title.localeCompare(right.title);
|
|
19
|
+
})
|
|
20
|
+
.slice(0, limit)
|
|
21
|
+
.map((issue) => ({
|
|
22
|
+
severity: issue.severity,
|
|
23
|
+
title: issue.title,
|
|
24
|
+
detail: issue.detail,
|
|
25
|
+
confidence: issue.confidence,
|
|
26
|
+
source: issue.source,
|
|
27
|
+
owasp: issue.owasp,
|
|
28
|
+
mitre: issue.mitre,
|
|
29
|
+
}));
|
|
30
|
+
export function buildPostureDigest(analysis, { findingLimit = 8 } = {}) {
|
|
31
|
+
const issues = normalizeArray(analysis.issues);
|
|
32
|
+
const compromiseIndicators = normalizeArray(analysis.compromiseSignals?.indicators);
|
|
33
|
+
const riskIndicators = compromiseIndicators.filter((indicator) => ["warning", "critical"].includes(indicator.severity));
|
|
34
|
+
return {
|
|
35
|
+
generatedAt: new Date().toISOString(),
|
|
36
|
+
target: {
|
|
37
|
+
inputUrl: analysis.inputUrl,
|
|
38
|
+
finalUrl: analysis.finalUrl,
|
|
39
|
+
host: analysis.host,
|
|
40
|
+
statusCode: analysis.statusCode,
|
|
41
|
+
responseTimeMs: analysis.responseTimeMs,
|
|
42
|
+
scannedAt: analysis.scannedAt,
|
|
43
|
+
},
|
|
44
|
+
posture: {
|
|
45
|
+
score: analysis.score,
|
|
46
|
+
grade: analysis.grade,
|
|
47
|
+
summary: analysis.summary,
|
|
48
|
+
overview: analysis.executiveSummary?.overview ?? null,
|
|
49
|
+
mainRisk: analysis.executiveSummary?.mainRisk ?? null,
|
|
50
|
+
posture: analysis.executiveSummary?.posture ?? null,
|
|
51
|
+
takeaways: normalizeArray(analysis.executiveSummary?.takeaways),
|
|
52
|
+
limited: analysis.assessmentLimitation?.limited ?? false,
|
|
53
|
+
limitedKind: analysis.assessmentLimitation?.kind ?? null,
|
|
54
|
+
limitation: analysis.assessmentLimitation ?? null,
|
|
55
|
+
scoreDrivers: normalizeArray(analysis.scoreDrivers).slice(0, 8),
|
|
56
|
+
},
|
|
57
|
+
findings: {
|
|
58
|
+
total: issues.length,
|
|
59
|
+
bySeverity: countIssuesBySeverity(issues),
|
|
60
|
+
top: topIssues(issues, findingLimit),
|
|
61
|
+
},
|
|
62
|
+
remediationPlan: analysis.remediationPlan ? {
|
|
63
|
+
summary: analysis.remediationPlan.summary,
|
|
64
|
+
totalActions: analysis.remediationPlan.totalActions,
|
|
65
|
+
highImpactActions: analysis.remediationPlan.highImpactActions,
|
|
66
|
+
quickWins: analysis.remediationPlan.quickWins,
|
|
67
|
+
topActions: normalizeArray(analysis.remediationPlan.items).slice(0, 5).map((item) => ({
|
|
68
|
+
id: item.id,
|
|
69
|
+
priority: item.priority,
|
|
70
|
+
title: item.title,
|
|
71
|
+
owner: item.owner,
|
|
72
|
+
effort: item.effort,
|
|
73
|
+
impact: item.impact,
|
|
74
|
+
action: item.action,
|
|
75
|
+
verify: item.verify,
|
|
76
|
+
relatedFindings: item.relatedFindings,
|
|
77
|
+
})),
|
|
78
|
+
} : null,
|
|
79
|
+
controls: {
|
|
80
|
+
headers: {
|
|
81
|
+
total: normalizeArray(analysis.headers).length,
|
|
82
|
+
missing: normalizeArray(analysis.headers).filter((header) => header.status === "missing").length,
|
|
83
|
+
warning: normalizeArray(analysis.headers).filter((header) => header.status === "warning").length,
|
|
84
|
+
present: normalizeArray(analysis.headers).filter((header) => header.status === "present").length,
|
|
85
|
+
},
|
|
86
|
+
cookies: {
|
|
87
|
+
total: normalizeArray(analysis.cookies).length,
|
|
88
|
+
issues: normalizeArray(analysis.cookieAnalysis?.issues),
|
|
89
|
+
},
|
|
90
|
+
tls: {
|
|
91
|
+
available: analysis.certificate?.available ?? false,
|
|
92
|
+
valid: analysis.certificate?.valid ?? false,
|
|
93
|
+
authorized: analysis.certificate?.authorized ?? false,
|
|
94
|
+
issuer: analysis.certificate?.issuer ?? null,
|
|
95
|
+
daysRemaining: analysis.certificate?.daysRemaining ?? null,
|
|
96
|
+
issues: normalizeArray(analysis.certificate?.issues),
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
surface: {
|
|
100
|
+
redirects: {
|
|
101
|
+
totalHops: analysis.redirectChain?.totalHops ?? normalizeArray(analysis.redirects).length,
|
|
102
|
+
hasMixedRedirect: analysis.redirectChain?.hasMixedRedirect ?? false,
|
|
103
|
+
crossesDomain: analysis.redirectChain?.crossesDomain ?? false,
|
|
104
|
+
issues: normalizeArray(analysis.redirectChain?.issues),
|
|
105
|
+
},
|
|
106
|
+
exposure: {
|
|
107
|
+
issues: normalizeArray(analysis.exposure?.issues),
|
|
108
|
+
interesting: normalizeArray(analysis.exposure?.probes).filter((probe) => probe.finding === "interesting").length,
|
|
109
|
+
exposed: normalizeArray(analysis.exposure?.probes).filter((probe) => probe.finding === "exposed").length,
|
|
110
|
+
},
|
|
111
|
+
api: {
|
|
112
|
+
issues: normalizeArray(analysis.apiSurface?.issues),
|
|
113
|
+
public: normalizeArray(analysis.apiSurface?.probes).filter((probe) => probe.classification === "public").length,
|
|
114
|
+
interesting: normalizeArray(analysis.apiSurface?.probes).filter((probe) => probe.classification === "interesting").length,
|
|
115
|
+
},
|
|
116
|
+
cors: {
|
|
117
|
+
issues: normalizeArray(analysis.corsSecurity?.issues),
|
|
118
|
+
allowCredentials: analysis.corsSecurity?.allowCredentials ?? null,
|
|
119
|
+
allowedOrigin: analysis.corsSecurity?.allowedOrigin ?? null,
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
trust: {
|
|
123
|
+
domainSecurity: {
|
|
124
|
+
emailDeliverabilityScore: analysis.domainSecurity?.emailDeliverabilityScore ?? null,
|
|
125
|
+
issues: normalizeArray(analysis.domainSecurity?.issues),
|
|
126
|
+
strengths: normalizeArray(analysis.domainSecurity?.strengths),
|
|
127
|
+
},
|
|
128
|
+
securityTxt: {
|
|
129
|
+
status: analysis.securityTxt?.status ?? null,
|
|
130
|
+
contact: normalizeArray(analysis.securityTxt?.contact),
|
|
131
|
+
},
|
|
132
|
+
thirdParty: {
|
|
133
|
+
providers: normalizeArray(analysis.thirdPartyTrust?.providers).map((provider) => provider.name),
|
|
134
|
+
highRiskProviders: analysis.thirdPartyTrust?.highRiskProviders ?? 0,
|
|
135
|
+
issues: normalizeArray(analysis.thirdPartyTrust?.issues),
|
|
136
|
+
},
|
|
137
|
+
identityProvider: analysis.identityProvider?.provider ?? null,
|
|
138
|
+
wafProviders: normalizeArray(analysis.wafFingerprint?.providers).map((provider) => provider.name),
|
|
139
|
+
infrastructureProviders: normalizeArray(analysis.infrastructure?.providers).map((provider) => provider.provider),
|
|
140
|
+
},
|
|
141
|
+
intelligence: {
|
|
142
|
+
passiveRead: analysis.passiveIntelligence?.postureRead ?? null,
|
|
143
|
+
compromisePosture: analysis.compromiseSignals?.posture ?? null,
|
|
144
|
+
compromiseSummary: analysis.compromiseSignals?.summary ?? null,
|
|
145
|
+
riskIndicators: riskIndicators.slice(0, 8).map((indicator) => ({
|
|
146
|
+
severity: indicator.severity,
|
|
147
|
+
category: indicator.category,
|
|
148
|
+
title: indicator.title,
|
|
149
|
+
detail: indicator.detail,
|
|
150
|
+
confidence: indicator.confidence,
|
|
151
|
+
})),
|
|
152
|
+
ctPriorityHosts: normalizeArray(analysis.ctDiscovery?.prioritizedHosts)
|
|
153
|
+
.slice(0, 10)
|
|
154
|
+
.map((host) => host.host),
|
|
155
|
+
aiVendors: normalizeArray(analysis.aiSurface?.vendors).map((vendor) => vendor.name),
|
|
156
|
+
},
|
|
157
|
+
timing: analysis.scanTiming ?? null,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { HistoryDiff, HistorySnapshot, PostureDriftReport, PostureRiskEvent } from "./types.js";
|
|
2
|
+
export declare function buildPostureDriftReportFromDiff(current: HistorySnapshot, previous: HistorySnapshot, diff: HistoryDiff, riskEvents?: PostureRiskEvent[]): PostureDriftReport;
|
|
3
|
+
export declare function buildPostureDriftReportFromSnapshots(current: HistorySnapshot, previous: HistorySnapshot): PostureDriftReport;
|
|
4
|
+
export declare function buildPostureDriftReport(history: HistorySnapshot[]): PostureDriftReport | null;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { buildHistoryDiffFromSnapshots } from "./historyDiff.js";
|
|
2
|
+
import { buildPostureRiskEventsFromSnapshots } from "./riskEvents.js";
|
|
3
|
+
const SEVERITY_ORDER = {
|
|
4
|
+
none: 0,
|
|
5
|
+
info: 1,
|
|
6
|
+
warning: 2,
|
|
7
|
+
critical: 3,
|
|
8
|
+
};
|
|
9
|
+
function snapshotSummary(snapshot) {
|
|
10
|
+
return {
|
|
11
|
+
finalUrl: snapshot.finalUrl,
|
|
12
|
+
host: snapshot.host,
|
|
13
|
+
scannedAt: snapshot.scannedAt,
|
|
14
|
+
score: snapshot.score,
|
|
15
|
+
grade: snapshot.grade,
|
|
16
|
+
statusCode: snapshot.statusCode,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function highestSeverity(events) {
|
|
20
|
+
return events.reduce((highest, event) => (SEVERITY_ORDER[event.severity] > SEVERITY_ORDER[highest] ? event.severity : highest), "none");
|
|
21
|
+
}
|
|
22
|
+
function countEvents(events) {
|
|
23
|
+
return events.reduce((counts, event) => {
|
|
24
|
+
counts[event.severity] += 1;
|
|
25
|
+
return counts;
|
|
26
|
+
}, {
|
|
27
|
+
critical: 0,
|
|
28
|
+
warning: 0,
|
|
29
|
+
info: 0,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
function sortEventsBySeverity(events) {
|
|
33
|
+
return [...events].sort((left, right) => {
|
|
34
|
+
const severityDelta = SEVERITY_ORDER[right.severity] - SEVERITY_ORDER[left.severity];
|
|
35
|
+
if (severityDelta !== 0) {
|
|
36
|
+
return severityDelta;
|
|
37
|
+
}
|
|
38
|
+
return left.eventType.localeCompare(right.eventType);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
function addArea(areas, condition, area) {
|
|
42
|
+
if (condition) {
|
|
43
|
+
areas.add(area);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function changedAreas(diff) {
|
|
47
|
+
const areas = new Set();
|
|
48
|
+
addArea(areas, typeof diff.scoreDelta === "number" && diff.scoreDelta !== 0, "score");
|
|
49
|
+
addArea(areas, diff.previousGrade !== diff.currentGrade, "grade");
|
|
50
|
+
addArea(areas, Boolean(diff.statusCodeDelta && diff.statusCodeDelta.from !== diff.statusCodeDelta.to), "status");
|
|
51
|
+
addArea(areas, Boolean(diff.certificateDaysRemainingDelta?.delta), "certificate");
|
|
52
|
+
addArea(areas, diff.headerChanges.length > 0, "headers");
|
|
53
|
+
addArea(areas, diff.newIssues.length > 0 || diff.resolvedIssues.length > 0, "findings");
|
|
54
|
+
addArea(areas, diff.newThirdPartyProviders.length > 0 || diff.removedThirdPartyProviders.length > 0, "third_party");
|
|
55
|
+
addArea(areas, diff.newAiVendors.length > 0 || diff.removedAiVendors.length > 0, "ai");
|
|
56
|
+
addArea(areas, Boolean(diff.identityProviderChange), "identity");
|
|
57
|
+
addArea(areas, diff.wafProviderChanges.newProviders.length > 0 || diff.wafProviderChanges.removedProviders.length > 0, "waf");
|
|
58
|
+
addArea(areas, diff.ctPriorityHostChanges.newHosts.length > 0 || diff.ctPriorityHostChanges.removedHosts.length > 0, "ct");
|
|
59
|
+
return [...areas];
|
|
60
|
+
}
|
|
61
|
+
function hasPositiveChange(diff) {
|
|
62
|
+
return ((typeof diff.scoreDelta === "number" && diff.scoreDelta > 0) ||
|
|
63
|
+
diff.resolvedIssues.length > 0 ||
|
|
64
|
+
diff.wafProviderChanges.newProviders.length > 0);
|
|
65
|
+
}
|
|
66
|
+
function hasNegativeChange(diff, events) {
|
|
67
|
+
return (events.some((event) => event.severity === "warning" || event.severity === "critical") ||
|
|
68
|
+
(typeof diff.scoreDelta === "number" && diff.scoreDelta < 0) ||
|
|
69
|
+
diff.newIssues.length > 0 ||
|
|
70
|
+
diff.wafProviderChanges.removedProviders.length > 0);
|
|
71
|
+
}
|
|
72
|
+
function buildDirection(diff, events) {
|
|
73
|
+
const negative = hasNegativeChange(diff, events);
|
|
74
|
+
const positive = hasPositiveChange(diff);
|
|
75
|
+
if (negative) {
|
|
76
|
+
return "regressed";
|
|
77
|
+
}
|
|
78
|
+
if (positive) {
|
|
79
|
+
return "improved";
|
|
80
|
+
}
|
|
81
|
+
if (changedAreas(diff).length > 0) {
|
|
82
|
+
return "changed";
|
|
83
|
+
}
|
|
84
|
+
return "unchanged";
|
|
85
|
+
}
|
|
86
|
+
export function buildPostureDriftReportFromDiff(current, previous, diff, riskEvents = buildPostureRiskEventsFromSnapshots(current, previous, diff)) {
|
|
87
|
+
const summaryItems = diff.summary.length ? diff.summary : ["No posture drift detected."];
|
|
88
|
+
const eventCounts = countEvents(riskEvents);
|
|
89
|
+
const topEvents = sortEventsBySeverity(riskEvents).slice(0, 5);
|
|
90
|
+
return {
|
|
91
|
+
current: snapshotSummary(current),
|
|
92
|
+
previous: snapshotSummary(previous),
|
|
93
|
+
diff,
|
|
94
|
+
riskEvents,
|
|
95
|
+
summary: {
|
|
96
|
+
direction: buildDirection(diff, riskEvents),
|
|
97
|
+
severity: highestSeverity(riskEvents),
|
|
98
|
+
scoreDelta: typeof diff.scoreDelta === "number" ? diff.scoreDelta : null,
|
|
99
|
+
gradeChanged: diff.previousGrade !== diff.currentGrade,
|
|
100
|
+
hasRegression: hasNegativeChange(diff, riskEvents),
|
|
101
|
+
hasImprovement: hasPositiveChange(diff),
|
|
102
|
+
eventCounts,
|
|
103
|
+
changedAreas: changedAreas(diff),
|
|
104
|
+
topEvents,
|
|
105
|
+
summary: summaryItems,
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
export function buildPostureDriftReportFromSnapshots(current, previous) {
|
|
110
|
+
const diff = buildHistoryDiffFromSnapshots(current, previous);
|
|
111
|
+
return buildPostureDriftReportFromDiff(current, previous, diff);
|
|
112
|
+
}
|
|
113
|
+
export function buildPostureDriftReport(history) {
|
|
114
|
+
if (history.length < 2) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
return buildPostureDriftReportFromSnapshots(history[0], history[1]);
|
|
118
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { AnalysisResult, RemediationPlan, ScanEvidenceReference, ScanIssue } from "./types.js";
|
|
2
|
+
export declare function buildIssueEvidence(issue: ScanIssue, analysis: AnalysisResult): ScanEvidenceReference[];
|
|
3
|
+
export declare function attachIssueEvidence(analysis: AnalysisResult): AnalysisResult;
|
|
4
|
+
export declare function buildPostureRemediationPlan(analysis: AnalysisResult, { limit }?: {
|
|
5
|
+
limit?: number;
|
|
6
|
+
}): RemediationPlan;
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
const normalizeArray = (value) => (Array.isArray(value) ? value : []);
|
|
2
|
+
const HEADER_AREA_KEYS = new Set(["edge", "content"]);
|
|
3
|
+
const slug = (value) => {
|
|
4
|
+
let output = "";
|
|
5
|
+
let pendingDash = false;
|
|
6
|
+
for (const char of value.toLowerCase()) {
|
|
7
|
+
const code = char.charCodeAt(0);
|
|
8
|
+
const isAsciiLetter = code >= 97 && code <= 122;
|
|
9
|
+
const isDigit = code >= 48 && code <= 57;
|
|
10
|
+
if (isAsciiLetter || isDigit) {
|
|
11
|
+
if (pendingDash && output.length > 0)
|
|
12
|
+
output += "-";
|
|
13
|
+
output += char;
|
|
14
|
+
pendingDash = false;
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
pendingDash = true;
|
|
18
|
+
}
|
|
19
|
+
if (output.length >= 80)
|
|
20
|
+
break;
|
|
21
|
+
}
|
|
22
|
+
return output || "remediation";
|
|
23
|
+
};
|
|
24
|
+
function issueSeverityRank(issue) {
|
|
25
|
+
if (issue.severity === "critical")
|
|
26
|
+
return 0;
|
|
27
|
+
if (issue.severity === "warning")
|
|
28
|
+
return 1;
|
|
29
|
+
return 2;
|
|
30
|
+
}
|
|
31
|
+
function impactFromScore(scoreImpact, issue) {
|
|
32
|
+
if (scoreImpact !== null && scoreImpact >= 15)
|
|
33
|
+
return "high";
|
|
34
|
+
if (issue?.severity === "critical")
|
|
35
|
+
return "high";
|
|
36
|
+
if (scoreImpact !== null && scoreImpact >= 5)
|
|
37
|
+
return "medium";
|
|
38
|
+
if (issue?.severity === "warning")
|
|
39
|
+
return "medium";
|
|
40
|
+
return "low";
|
|
41
|
+
}
|
|
42
|
+
function ownerForArea(areaKey) {
|
|
43
|
+
if (areaKey === "domain")
|
|
44
|
+
return "dns";
|
|
45
|
+
if (areaKey === "trust" || areaKey === "ai")
|
|
46
|
+
return "third_party";
|
|
47
|
+
if (areaKey === "headers" || areaKey === "edge" || areaKey === "content")
|
|
48
|
+
return "edge";
|
|
49
|
+
if (areaKey === "certificate" || areaKey === "transport")
|
|
50
|
+
return "edge";
|
|
51
|
+
return "app";
|
|
52
|
+
}
|
|
53
|
+
function effortFor(owner, title) {
|
|
54
|
+
const lower = title.toLowerCase();
|
|
55
|
+
if (owner === "dns" || lower.includes("content-security-policy") || lower.includes("csp"))
|
|
56
|
+
return "medium";
|
|
57
|
+
if (lower.includes("third-party") || lower.includes("identity"))
|
|
58
|
+
return "medium";
|
|
59
|
+
if (lower.includes("limited assessment") || lower.includes("service unavailable"))
|
|
60
|
+
return "high";
|
|
61
|
+
return "low";
|
|
62
|
+
}
|
|
63
|
+
function evidenceKindForScoreSource(source) {
|
|
64
|
+
if (source === "tls")
|
|
65
|
+
return "tls";
|
|
66
|
+
if (source === "cookies")
|
|
67
|
+
return "cookie";
|
|
68
|
+
if (source === "dns")
|
|
69
|
+
return "dns";
|
|
70
|
+
if (source === "html")
|
|
71
|
+
return "html";
|
|
72
|
+
if (source === "public_record")
|
|
73
|
+
return "public_record";
|
|
74
|
+
return "score_driver";
|
|
75
|
+
}
|
|
76
|
+
function headerEvidenceForIssue(issue, headers) {
|
|
77
|
+
const issueText = `${issue.title} ${issue.detail}`.toLowerCase();
|
|
78
|
+
const matched = headers.find((header) => issueText.includes(header.label.toLowerCase()) || issueText.includes(header.key.toLowerCase()));
|
|
79
|
+
if (!matched)
|
|
80
|
+
return [];
|
|
81
|
+
return [{
|
|
82
|
+
kind: "header",
|
|
83
|
+
label: matched.label,
|
|
84
|
+
observed: matched.value ?? matched.status,
|
|
85
|
+
expected: matched.recommendation,
|
|
86
|
+
source: issue.source,
|
|
87
|
+
}];
|
|
88
|
+
}
|
|
89
|
+
function cookieEvidenceForIssue(issue, analysis) {
|
|
90
|
+
const titleLower = issue.title.toLowerCase();
|
|
91
|
+
const prefix = "cookie ";
|
|
92
|
+
const suffix = " needs attention";
|
|
93
|
+
const cookieName = titleLower.startsWith(prefix) && titleLower.endsWith(suffix)
|
|
94
|
+
? issue.title.slice(prefix.length, issue.title.length - suffix.length).trim() || null
|
|
95
|
+
: null;
|
|
96
|
+
const cookie = cookieName
|
|
97
|
+
? normalizeArray(analysis.cookies).find((item) => item.name === cookieName)
|
|
98
|
+
: null;
|
|
99
|
+
if (!cookieName && !cookie)
|
|
100
|
+
return [];
|
|
101
|
+
return [{
|
|
102
|
+
kind: "cookie",
|
|
103
|
+
label: cookieName ?? cookie?.name ?? "Cookie",
|
|
104
|
+
observed: cookie
|
|
105
|
+
? [
|
|
106
|
+
cookie.secure ? "Secure" : "missing Secure",
|
|
107
|
+
cookie.httpOnly ? "HttpOnly" : "missing HttpOnly",
|
|
108
|
+
cookie.sameSite ? `SameSite=${cookie.sameSite}` : "missing SameSite",
|
|
109
|
+
].join(", ")
|
|
110
|
+
: issue.detail,
|
|
111
|
+
expected: "Session and authentication cookies should use Secure, HttpOnly, and an appropriate SameSite policy.",
|
|
112
|
+
source: issue.source,
|
|
113
|
+
}];
|
|
114
|
+
}
|
|
115
|
+
export function buildIssueEvidence(issue, analysis) {
|
|
116
|
+
const evidence = [
|
|
117
|
+
...normalizeArray(issue.evidence),
|
|
118
|
+
...headerEvidenceForIssue(issue, normalizeArray(analysis.headers)),
|
|
119
|
+
...cookieEvidenceForIssue(issue, analysis),
|
|
120
|
+
];
|
|
121
|
+
if (issue.area === "transport" && issue.title.toLowerCase().includes("https")) {
|
|
122
|
+
evidence.push({
|
|
123
|
+
kind: "tls",
|
|
124
|
+
label: "Final URL",
|
|
125
|
+
observed: analysis.finalUrl,
|
|
126
|
+
expected: "HTTPS with a trusted certificate chain.",
|
|
127
|
+
url: analysis.finalUrl,
|
|
128
|
+
source: issue.source,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
if (issue.area === "certificate") {
|
|
132
|
+
evidence.push({
|
|
133
|
+
kind: "tls",
|
|
134
|
+
label: analysis.certificate?.subject ?? analysis.host,
|
|
135
|
+
observed: normalizeArray(analysis.certificate?.issues).join("; ") || analysis.certificate?.issuer || null,
|
|
136
|
+
expected: "Valid, trusted certificate with comfortable renewal runway.",
|
|
137
|
+
source: issue.source,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
if (issue.title.toLowerCase().includes("redirect")) {
|
|
141
|
+
evidence.push({
|
|
142
|
+
kind: "redirect",
|
|
143
|
+
label: "Redirect chain",
|
|
144
|
+
observed: `${analysis.redirectChain?.totalHops ?? normalizeArray(analysis.redirects).length} hop(s)`,
|
|
145
|
+
expected: "Short HTTPS-only redirect chain.",
|
|
146
|
+
url: analysis.finalUrl,
|
|
147
|
+
source: issue.source,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
const seen = new Set();
|
|
151
|
+
return evidence.filter((item) => {
|
|
152
|
+
const key = `${item.kind}:${item.label}:${item.observed ?? ""}`;
|
|
153
|
+
if (seen.has(key))
|
|
154
|
+
return false;
|
|
155
|
+
seen.add(key);
|
|
156
|
+
return true;
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
export function attachIssueEvidence(analysis) {
|
|
160
|
+
return {
|
|
161
|
+
...analysis,
|
|
162
|
+
issues: normalizeArray(analysis.issues).map((issue) => ({
|
|
163
|
+
...issue,
|
|
164
|
+
evidence: buildIssueEvidence(issue, analysis),
|
|
165
|
+
})),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
function relatedFindingsForDriver(driver, issues) {
|
|
169
|
+
const driverText = `${driver.areaKey} ${driver.label} ${driver.detail}`.toLowerCase();
|
|
170
|
+
return issues
|
|
171
|
+
.filter((issue) => {
|
|
172
|
+
if (HEADER_AREA_KEYS.has(driver.areaKey) && issue.area === "headers")
|
|
173
|
+
return true;
|
|
174
|
+
if (driver.source === "cookies" && issue.area === "cookies")
|
|
175
|
+
return true;
|
|
176
|
+
if (driver.source === "tls" && (issue.area === "certificate" || issue.area === "transport"))
|
|
177
|
+
return true;
|
|
178
|
+
return driverText.includes(issue.title.toLowerCase());
|
|
179
|
+
})
|
|
180
|
+
.sort((left, right) => issueSeverityRank(left) - issueSeverityRank(right))
|
|
181
|
+
.slice(0, 4)
|
|
182
|
+
.map((issue) => issue.title);
|
|
183
|
+
}
|
|
184
|
+
function actionFor(owner, title, detail) {
|
|
185
|
+
const lower = `${title} ${detail}`.toLowerCase();
|
|
186
|
+
if (lower.includes("content-security-policy") || lower.includes("csp")) {
|
|
187
|
+
return "Define a deployable CSP baseline, test it in report-only mode if needed, then enforce it on the edge or app response.";
|
|
188
|
+
}
|
|
189
|
+
if (lower.includes("header")) {
|
|
190
|
+
return "Set the missing or weak browser security headers at the edge, reverse proxy, or application response layer.";
|
|
191
|
+
}
|
|
192
|
+
if (owner === "dns") {
|
|
193
|
+
return "Update DNS/provider configuration, then rescan once records have propagated.";
|
|
194
|
+
}
|
|
195
|
+
if (lower.includes("cookie")) {
|
|
196
|
+
return "Adjust application cookie attributes for session-sensitive cookies and verify the Set-Cookie response.";
|
|
197
|
+
}
|
|
198
|
+
if (lower.includes("certificate") || lower.includes("tls")) {
|
|
199
|
+
return "Review certificate issuance, renewal, and TLS termination configuration on the serving edge.";
|
|
200
|
+
}
|
|
201
|
+
if (owner === "third_party") {
|
|
202
|
+
return "Confirm ownership, data handling, and necessity for the observed third-party surface.";
|
|
203
|
+
}
|
|
204
|
+
return "Review the finding, apply the smallest safe configuration change, then rescan the same URL.";
|
|
205
|
+
}
|
|
206
|
+
function verifyFor(owner) {
|
|
207
|
+
if (owner === "dns")
|
|
208
|
+
return "Rescan after DNS propagation and confirm Domain & Trust findings are reduced.";
|
|
209
|
+
if (owner === "third_party")
|
|
210
|
+
return "Rescan and confirm the vendor signal is either disclosed, justified, or removed.";
|
|
211
|
+
return "Rescan the target and confirm the related finding is resolved or downgraded.";
|
|
212
|
+
}
|
|
213
|
+
function itemFromDriver(driver, analysis, priority) {
|
|
214
|
+
const issues = normalizeArray(analysis.issues);
|
|
215
|
+
const owner = ownerForArea(driver.areaKey);
|
|
216
|
+
const relatedFindings = relatedFindingsForDriver(driver, issues);
|
|
217
|
+
return {
|
|
218
|
+
id: slug(`${driver.areaKey}-${driver.label}`),
|
|
219
|
+
priority,
|
|
220
|
+
title: driver.label,
|
|
221
|
+
detail: driver.detail,
|
|
222
|
+
owner,
|
|
223
|
+
effort: effortFor(owner, driver.label),
|
|
224
|
+
impact: impactFromScore(driver.impact),
|
|
225
|
+
action: actionFor(owner, driver.label, driver.detail),
|
|
226
|
+
verify: verifyFor(owner),
|
|
227
|
+
scoreImpact: driver.impact,
|
|
228
|
+
relatedFindings,
|
|
229
|
+
evidence: [{
|
|
230
|
+
kind: evidenceKindForScoreSource(driver.source),
|
|
231
|
+
label: driver.areaLabel,
|
|
232
|
+
observed: driver.detail,
|
|
233
|
+
source: driver.source,
|
|
234
|
+
}],
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
function itemFromIssue(issue, analysis, priority) {
|
|
238
|
+
const owner = ownerForArea(issue.area);
|
|
239
|
+
return {
|
|
240
|
+
id: slug(`${issue.area}-${issue.title}`),
|
|
241
|
+
priority,
|
|
242
|
+
title: issue.title,
|
|
243
|
+
detail: issue.detail,
|
|
244
|
+
owner,
|
|
245
|
+
effort: effortFor(owner, issue.title),
|
|
246
|
+
impact: impactFromScore(null, issue),
|
|
247
|
+
action: actionFor(owner, issue.title, issue.detail),
|
|
248
|
+
verify: verifyFor(owner),
|
|
249
|
+
scoreImpact: null,
|
|
250
|
+
relatedFindings: [issue.title],
|
|
251
|
+
evidence: buildIssueEvidence(issue, analysis),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
export function buildPostureRemediationPlan(analysis, { limit = 10 } = {}) {
|
|
255
|
+
const issues = normalizeArray(analysis.issues);
|
|
256
|
+
const driverItems = normalizeArray(analysis.scoreDrivers)
|
|
257
|
+
.filter((driver) => driver.impact > 0)
|
|
258
|
+
.map((driver, index) => itemFromDriver(driver, analysis, index + 1));
|
|
259
|
+
const existingTitles = new Set(driverItems.map((item) => item.title.toLowerCase()));
|
|
260
|
+
const issueItems = issues
|
|
261
|
+
.filter((issue) => !existingTitles.has(issue.title.toLowerCase()))
|
|
262
|
+
.sort((left, right) => issueSeverityRank(left) - issueSeverityRank(right) || left.title.localeCompare(right.title))
|
|
263
|
+
.map((issue, index) => itemFromIssue(issue, analysis, driverItems.length + index + 1));
|
|
264
|
+
const deduped = [];
|
|
265
|
+
const seen = new Set();
|
|
266
|
+
for (const item of [...driverItems, ...issueItems]) {
|
|
267
|
+
if (seen.has(item.id))
|
|
268
|
+
continue;
|
|
269
|
+
seen.add(item.id);
|
|
270
|
+
deduped.push({ ...item, priority: deduped.length + 1 });
|
|
271
|
+
if (deduped.length >= limit)
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
const highImpactActions = deduped.filter((item) => item.impact === "high").length;
|
|
275
|
+
const quickWins = deduped.filter((item) => item.effort === "low").length;
|
|
276
|
+
return {
|
|
277
|
+
generatedAt: new Date().toISOString(),
|
|
278
|
+
summary: deduped.length
|
|
279
|
+
? `${deduped.length} prioritized remediation action${deduped.length === 1 ? "" : "s"} generated from score drivers and findings.`
|
|
280
|
+
: "No remediation actions were generated from the current passive evidence.",
|
|
281
|
+
totalActions: deduped.length,
|
|
282
|
+
highImpactActions,
|
|
283
|
+
quickWins,
|
|
284
|
+
items: deduped,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { getSiteDomain } from "./utils.js";
|
|
2
|
+
const redirectStatuses = new Set([301, 302, 303, 307, 308]);
|
|
3
|
+
export function analyzeRedirectChain(submittedUrl, finalUrl, redirects) {
|
|
4
|
+
const hops = redirects.map((hop) => ({
|
|
5
|
+
...hop,
|
|
6
|
+
status: hop.status ?? hop.statusCode,
|
|
7
|
+
isHttps: hop.isHttps ?? hop.secure,
|
|
8
|
+
}));
|
|
9
|
+
const redirectHops = hops.filter((hop) => redirectStatuses.has(hop.status));
|
|
10
|
+
const startedHttps = submittedUrl.protocol === "https:";
|
|
11
|
+
const endedHttps = finalUrl.protocol === "https:";
|
|
12
|
+
const hasMixedRedirect = startedHttps && endedHttps && hops.some((hop) => !hop.isHttps);
|
|
13
|
+
const isLongChain = redirectHops.length > 3;
|
|
14
|
+
const crossesDomain = getSiteDomain(submittedUrl.hostname) !== getSiteDomain(finalUrl.hostname);
|
|
15
|
+
const issues = [];
|
|
16
|
+
const strengths = [];
|
|
17
|
+
if (hasMixedRedirect) {
|
|
18
|
+
issues.push("Redirect chain includes an HTTP hop before reaching the final HTTPS URL.");
|
|
19
|
+
}
|
|
20
|
+
if (isLongChain) {
|
|
21
|
+
issues.push("Redirect chain is longer than three hops, which adds latency and can make policy enforcement harder to reason about.");
|
|
22
|
+
}
|
|
23
|
+
if (crossesDomain) {
|
|
24
|
+
issues.push("Final URL resolves to a different registrable domain than the submitted URL.");
|
|
25
|
+
}
|
|
26
|
+
if (!issues.length) {
|
|
27
|
+
strengths.push("Redirect chain stayed short, HTTPS-only, and on the expected domain.");
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
hops,
|
|
31
|
+
finalUrl: finalUrl.toString(),
|
|
32
|
+
totalHops: redirectHops.length,
|
|
33
|
+
hasMixedRedirect,
|
|
34
|
+
isLongChain,
|
|
35
|
+
crossesDomain,
|
|
36
|
+
issues,
|
|
37
|
+
strengths,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { HistoryDiff, HistorySnapshot, PostureRiskEvent } from "./types.js";
|
|
2
|
+
export declare function buildPostureRiskEventsFromDiff(diff: HistoryDiff | null | undefined): PostureRiskEvent[];
|
|
3
|
+
export declare function buildPostureRiskEventsFromSnapshots(current: HistorySnapshot, previous: HistorySnapshot, diff: HistoryDiff): PostureRiskEvent[];
|