securl 1.8.0 → 1.10.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/CHANGELOG.md +6 -0
- package/README.md +38 -1
- package/dist/certificate.d.ts +2 -1
- package/dist/certificate.js +132 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +18 -3
- package/dist/observations.d.ts +2 -0
- package/dist/observations.js +203 -0
- package/dist/types.d.ts +45 -0
- package/package.json +9 -1
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,12 @@ The format is based on Keep a Changelog and this package follows Semantic Versio
|
|
|
12
12
|
- Added `buildExposureBrief()` for compact outside-observer action briefs covering public entry points, sensitive exposures, trust gaps, abuse indicators, third-party risk, AI surface signals, and next actions.
|
|
13
13
|
- Added `exposureBrief` to analysis results and the `securl/exposure-brief` package export for SDK consumers.
|
|
14
14
|
|
|
15
|
+
## [1.10.0] - 2026-06-20
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- Added `buildObservationLedger()` and the `securl/observations` package export for stable, source-aware posture observations.
|
|
19
|
+
- Added `observationLedger` to completed analysis results with deterministic IDs, confidence, status, and freshness metadata.
|
|
20
|
+
|
|
15
21
|
## [1.5.1] - 2026-06-15
|
|
16
22
|
|
|
17
23
|
### Changed
|
package/README.md
CHANGED
|
@@ -194,7 +194,40 @@ console.log({
|
|
|
194
194
|
|
|
195
195
|
Action-plan items include owner, effort, impact, confidence, score impact where available, evidence references, and verification guidance.
|
|
196
196
|
|
|
197
|
-
###
|
|
197
|
+
### 7. Live certificate checks
|
|
198
|
+
|
|
199
|
+
Version `1.9.0+` includes a lightweight certificate helper for Cert Watch-style clients that only need the currently served TLS certificate.
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
import { scanLiveCertificate } from "securl/live-certificate";
|
|
203
|
+
|
|
204
|
+
const certificate = await scanLiveCertificate(new URL("https://example.com"));
|
|
205
|
+
|
|
206
|
+
console.log({
|
|
207
|
+
issuer: certificate.issuer,
|
|
208
|
+
daysRemaining: certificate.daysRemaining,
|
|
209
|
+
protocol: certificate.protocol,
|
|
210
|
+
chainLength: certificate.chain.length,
|
|
211
|
+
});
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### 8. Machine-readable observation ledger
|
|
215
|
+
|
|
216
|
+
Version `1.10.0+` adds stable posture observations for monitoring, inventory, policy, and future SaaS integrations. Each observation records what was seen, whether it was observed, inferred, missing, or unavailable, its confidence and source, and when that evidence should be refreshed.
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
import { analyzeUrl } from "securl";
|
|
220
|
+
import { buildObservationLedger } from "securl/observations";
|
|
221
|
+
|
|
222
|
+
const result = await analyzeUrl("https://example.com");
|
|
223
|
+
const ledger = result.observationLedger ?? buildObservationLedger(result);
|
|
224
|
+
|
|
225
|
+
console.log(ledger.summary, ledger.observations);
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Observation IDs are deterministic across scans for the same subject and signal, making the ledger suitable for change detection without exposing backend job metadata.
|
|
229
|
+
|
|
230
|
+
### 9. Evidence-backed remediation plans
|
|
198
231
|
|
|
199
232
|
Version `1.4.0+` includes a remediation plan helper that turns score drivers and findings into prioritized, owner-aware fix guidance. Findings can also carry structured evidence references so clients can show why a finding was raised.
|
|
200
233
|
|
|
@@ -304,6 +337,8 @@ Primary exports:
|
|
|
304
337
|
- `buildPostureRiskEventsFromSnapshots(current, previous, diff)` - classify scan changes into alert-friendly risk events.
|
|
305
338
|
- `buildPostureDigest(result)` - reduce a full scan result to a compact API/mobile-friendly digest.
|
|
306
339
|
- `buildActionPlan(result)` - turn remediation, score drivers, exposure, and vendor context into prioritized fix actions.
|
|
340
|
+
- `scanLiveCertificate(url)` - perform a TLS handshake-only certificate read for lightweight cert monitoring.
|
|
341
|
+
- `buildObservationLedger(result)` - produce stable source, confidence, status, and freshness-aware posture observations.
|
|
307
342
|
- `buildPostureDriftReportFromSnapshots(current, previous)` - produce a complete scan-to-scan drift report for monitoring, alerting, and history views.
|
|
308
343
|
- `buildPostureRemediationPlan(result)` - generate prioritized, owner-aware remediation actions from findings and score drivers.
|
|
309
344
|
- `attachIssueEvidence(result)` - add structured evidence references to findings without changing their existing fields.
|
|
@@ -314,6 +349,8 @@ Package subpath exports:
|
|
|
314
349
|
- `securl/history-diff`
|
|
315
350
|
- `securl/posture-digest`
|
|
316
351
|
- `securl/action-plan`
|
|
352
|
+
- `securl/live-certificate`
|
|
353
|
+
- `securl/observations`
|
|
317
354
|
- `securl/posture-drift`
|
|
318
355
|
- `securl/remediation-plan`
|
|
319
356
|
- `securl/risk-events`
|
package/dist/certificate.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type { CertificateResult } from "./types.js";
|
|
1
|
+
import type { CertificateResult, LiveCertificateResult } from "./types.js";
|
|
2
2
|
export declare const OBSERVATIONAL_TLS_OPTIONS: {
|
|
3
3
|
rejectUnauthorized: boolean;
|
|
4
4
|
};
|
|
5
5
|
export declare const scanTls: (targetUrl: URL) => Promise<CertificateResult>;
|
|
6
|
+
export declare const scanLiveCertificate: (targetUrl: URL) => Promise<LiveCertificateResult>;
|
package/dist/certificate.js
CHANGED
|
@@ -16,6 +16,48 @@ export const OBSERVATIONAL_TLS_OPTIONS = {
|
|
|
16
16
|
// Set EXTERNAL_POSTURE_ALLOW_INSECURE_TLS=1 only for controlled observational runs.
|
|
17
17
|
rejectUnauthorized: !allowInsecureTls,
|
|
18
18
|
};
|
|
19
|
+
function certificateName(certificate, field) {
|
|
20
|
+
return firstStringValue(certificate?.[field]?.O) ?? firstStringValue(certificate?.[field]?.CN);
|
|
21
|
+
}
|
|
22
|
+
function chainFromCertificate(certificate) {
|
|
23
|
+
const chain = [];
|
|
24
|
+
const seen = new Set();
|
|
25
|
+
let current = certificate;
|
|
26
|
+
while (current && Object.keys(current).length > 0) {
|
|
27
|
+
const fingerprint = current.fingerprint256 || current.fingerprint || null;
|
|
28
|
+
const key = fingerprint || `${current.subject?.CN || ""}:${current.issuer?.CN || ""}:${current.valid_to || ""}`;
|
|
29
|
+
if (seen.has(key)) {
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
seen.add(key);
|
|
33
|
+
chain.push({
|
|
34
|
+
subject: certificateName(current, "subject"),
|
|
35
|
+
issuer: certificateName(current, "issuer"),
|
|
36
|
+
validFrom: current.valid_from || null,
|
|
37
|
+
validTo: current.valid_to || null,
|
|
38
|
+
fingerprint,
|
|
39
|
+
});
|
|
40
|
+
if (!current.issuerCertificate || current.issuerCertificate === current) {
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
current = current.issuerCertificate;
|
|
44
|
+
}
|
|
45
|
+
return chain;
|
|
46
|
+
}
|
|
47
|
+
function keyBitsFromCertificate(certificate) {
|
|
48
|
+
const value = certificate?.bits;
|
|
49
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
50
|
+
}
|
|
51
|
+
function keyTypeFromCertificate(certificate) {
|
|
52
|
+
const value = certificate?.asn1Curve || certificate?.nistCurve;
|
|
53
|
+
if (typeof value === "string" && value.trim()) {
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
if (typeof certificate?.bits === "number") {
|
|
57
|
+
return "rsa";
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
19
61
|
export const scanTls = (targetUrl) => {
|
|
20
62
|
if (targetUrl.protocol !== "https:") {
|
|
21
63
|
return Promise.resolve({
|
|
@@ -71,8 +113,89 @@ export const scanTls = (targetUrl) => {
|
|
|
71
113
|
available: true,
|
|
72
114
|
valid: Boolean(socket.authorized),
|
|
73
115
|
authorized: Boolean(socket.authorized),
|
|
74
|
-
issuer:
|
|
75
|
-
subject:
|
|
116
|
+
issuer: certificateName(certificate, "issuer"),
|
|
117
|
+
subject: certificateName(certificate, "subject"),
|
|
118
|
+
validFrom,
|
|
119
|
+
validTo,
|
|
120
|
+
daysRemaining,
|
|
121
|
+
protocol,
|
|
122
|
+
cipher: cipherInfo?.name || null,
|
|
123
|
+
fingerprint: certificate?.fingerprint256 || null,
|
|
124
|
+
subjectAltName,
|
|
125
|
+
issues,
|
|
126
|
+
});
|
|
127
|
+
socket.end();
|
|
128
|
+
});
|
|
129
|
+
socket.once("timeout", () => {
|
|
130
|
+
socket.destroy(new Error("TLS handshake timed out."));
|
|
131
|
+
});
|
|
132
|
+
socket.once("error", reject);
|
|
133
|
+
});
|
|
134
|
+
};
|
|
135
|
+
export const scanLiveCertificate = (targetUrl) => {
|
|
136
|
+
if (targetUrl.protocol !== "https:") {
|
|
137
|
+
return Promise.resolve({
|
|
138
|
+
available: false,
|
|
139
|
+
valid: false,
|
|
140
|
+
authorized: false,
|
|
141
|
+
issuer: null,
|
|
142
|
+
subject: null,
|
|
143
|
+
validFrom: null,
|
|
144
|
+
validTo: null,
|
|
145
|
+
daysRemaining: null,
|
|
146
|
+
protocol: null,
|
|
147
|
+
cipher: null,
|
|
148
|
+
fingerprint: null,
|
|
149
|
+
subjectAltName: [],
|
|
150
|
+
issues: ["TLS certificate data is only available for HTTPS targets."],
|
|
151
|
+
host: targetUrl.hostname,
|
|
152
|
+
port: Number(targetUrl.port || 443),
|
|
153
|
+
checkedAt: new Date().toISOString(),
|
|
154
|
+
serialNumber: null,
|
|
155
|
+
keyBits: null,
|
|
156
|
+
keyType: null,
|
|
157
|
+
chain: [],
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
return new Promise((resolve, reject) => {
|
|
161
|
+
const socket = tls.connect({
|
|
162
|
+
host: targetUrl.hostname,
|
|
163
|
+
port: Number(targetUrl.port || 443),
|
|
164
|
+
servername: targetUrl.hostname,
|
|
165
|
+
...OBSERVATIONAL_TLS_OPTIONS,
|
|
166
|
+
timeout: TLS_HANDSHAKE_TIMEOUT_MS,
|
|
167
|
+
});
|
|
168
|
+
socket.once("secureConnect", () => {
|
|
169
|
+
const certificate = socket.getPeerCertificate(true);
|
|
170
|
+
const protocol = socket.getProtocol?.() || null;
|
|
171
|
+
const cipherInfo = socket.getCipher?.();
|
|
172
|
+
const validTo = certificate?.valid_to || null;
|
|
173
|
+
const validFrom = certificate?.valid_from || null;
|
|
174
|
+
const daysRemaining = validTo
|
|
175
|
+
? Math.ceil((new Date(validTo).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
|
|
176
|
+
: null;
|
|
177
|
+
const subjectAltName = typeof certificate?.subjectaltname === "string"
|
|
178
|
+
? certificate.subjectaltname.split(",").map((entry) => entry.trim().replace(/^DNS:/, ""))
|
|
179
|
+
: [];
|
|
180
|
+
const issues = [];
|
|
181
|
+
if (!socket.authorized) {
|
|
182
|
+
issues.push(typeof socket.authorizationError === "string"
|
|
183
|
+
? socket.authorizationError
|
|
184
|
+
: "Certificate is not trusted.");
|
|
185
|
+
}
|
|
186
|
+
if (allowInsecureTls) {
|
|
187
|
+
issues.push("Insecure TLS observation mode is enabled via EXTERNAL_POSTURE_ALLOW_INSECURE_TLS.");
|
|
188
|
+
}
|
|
189
|
+
if (daysRemaining !== null && daysRemaining <= 14)
|
|
190
|
+
issues.push("Certificate expires very soon.");
|
|
191
|
+
if (protocol && /tlsv1(\.0|\.1)?$/i.test(protocol))
|
|
192
|
+
issues.push("TLS protocol is outdated.");
|
|
193
|
+
resolve({
|
|
194
|
+
available: true,
|
|
195
|
+
valid: Boolean(socket.authorized),
|
|
196
|
+
authorized: Boolean(socket.authorized),
|
|
197
|
+
issuer: certificateName(certificate, "issuer"),
|
|
198
|
+
subject: certificateName(certificate, "subject"),
|
|
76
199
|
validFrom,
|
|
77
200
|
validTo,
|
|
78
201
|
daysRemaining,
|
|
@@ -81,6 +204,13 @@ export const scanTls = (targetUrl) => {
|
|
|
81
204
|
fingerprint: certificate?.fingerprint256 || null,
|
|
82
205
|
subjectAltName,
|
|
83
206
|
issues,
|
|
207
|
+
host: targetUrl.hostname,
|
|
208
|
+
port: Number(targetUrl.port || 443),
|
|
209
|
+
checkedAt: new Date().toISOString(),
|
|
210
|
+
serialNumber: certificate?.serialNumber || null,
|
|
211
|
+
keyBits: keyBitsFromCertificate(certificate),
|
|
212
|
+
keyType: keyTypeFromCertificate(certificate),
|
|
213
|
+
chain: chainFromCertificate(certificate),
|
|
84
214
|
});
|
|
85
215
|
socket.end();
|
|
86
216
|
});
|
package/dist/index.d.ts
CHANGED
|
@@ -11,7 +11,9 @@ export declare function analyzeUrl(input: string, options?: AnalyzeTargetOptions
|
|
|
11
11
|
export declare const analyzeTarget: typeof analyzeUrl;
|
|
12
12
|
export { formatErrorMessage };
|
|
13
13
|
export { buildCompromiseSignals, emptyCompromiseSignals } from "./compromiseSignals.js";
|
|
14
|
+
export { buildObservationLedger } from "./observations.js";
|
|
14
15
|
export { buildActionPlan } from "./actionPlan.js";
|
|
16
|
+
export { scanLiveCertificate } from "./certificate.js";
|
|
15
17
|
export { buildExposureBrief } from "./exposureBrief.js";
|
|
16
18
|
export { buildVendorExposureBrief } from "./vendorExposure.js";
|
|
17
19
|
export { analyzeInfrastructure } from "./infrastructure.js";
|
package/dist/index.js
CHANGED
|
@@ -19,6 +19,7 @@ import { analyzeApiSurface, analyzeCorsSecurity, analyzeExposure, fetchPublicSig
|
|
|
19
19
|
import { fetchLibraryRiskSignals } from "./libraryRisk.js";
|
|
20
20
|
import { fetchWithRedirects, requestJson, requestOnce, requestText, requestWithHeaders, } from "./network.js";
|
|
21
21
|
import { normalizeDiscoveredPath, rankDiscoveredPaths } from "./path-discovery.js";
|
|
22
|
+
import { buildObservationLedger } from "./observations.js";
|
|
22
23
|
import { buildPassiveIntelligence, emptyPassiveIntelligence } from "./passive-intelligence.js";
|
|
23
24
|
import { analyzeRedirectChain } from "./redirectChain.js";
|
|
24
25
|
import { attachIssueEvidence, buildPostureEvidenceSummary, buildPostureRemediationPlan } from "./postureRemediation.js";
|
|
@@ -514,10 +515,14 @@ async function buildLimitedResult(input, normalizedInput, failure, scanTiming) {
|
|
|
514
515
|
exposureBrief: buildExposureBrief(resultWithRemediation),
|
|
515
516
|
vendorExposure: buildVendorExposureBrief(resultWithRemediation),
|
|
516
517
|
};
|
|
517
|
-
|
|
518
|
+
const resultWithActions = {
|
|
518
519
|
...resultWithBriefs,
|
|
519
520
|
actionPlan: buildActionPlan(resultWithBriefs),
|
|
520
521
|
};
|
|
522
|
+
return {
|
|
523
|
+
...resultWithActions,
|
|
524
|
+
observationLedger: buildObservationLedger(resultWithActions),
|
|
525
|
+
};
|
|
521
526
|
}
|
|
522
527
|
async function enrichCoreResult(result, profile) {
|
|
523
528
|
const finalUrl = new URL(result.finalUrl);
|
|
@@ -953,10 +958,14 @@ function buildTimedOutEnrichmentResult(result, pageAnalysisEnabled, timeoutMs, c
|
|
|
953
958
|
exposureBrief: buildExposureBrief(resultWithRemediation),
|
|
954
959
|
vendorExposure: buildVendorExposureBrief(resultWithRemediation),
|
|
955
960
|
};
|
|
956
|
-
|
|
961
|
+
const resultWithActions = {
|
|
957
962
|
...resultWithBriefs,
|
|
958
963
|
actionPlan: buildActionPlan(resultWithBriefs),
|
|
959
964
|
};
|
|
965
|
+
return {
|
|
966
|
+
...resultWithActions,
|
|
967
|
+
observationLedger: buildObservationLedger(resultWithActions),
|
|
968
|
+
};
|
|
960
969
|
}
|
|
961
970
|
export async function analyzeUrl(input, options = {}) {
|
|
962
971
|
const scanStartedAt = Date.now();
|
|
@@ -1031,15 +1040,21 @@ export async function analyzeUrl(input, options = {}) {
|
|
|
1031
1040
|
exposureBrief: buildExposureBrief(resultWithRemediation),
|
|
1032
1041
|
vendorExposure: buildVendorExposureBrief(resultWithRemediation),
|
|
1033
1042
|
};
|
|
1034
|
-
|
|
1043
|
+
const resultWithActions = {
|
|
1035
1044
|
...resultWithBriefs,
|
|
1036
1045
|
actionPlan: buildActionPlan(resultWithBriefs),
|
|
1037
1046
|
};
|
|
1047
|
+
return {
|
|
1048
|
+
...resultWithActions,
|
|
1049
|
+
observationLedger: buildObservationLedger(resultWithActions),
|
|
1050
|
+
};
|
|
1038
1051
|
}
|
|
1039
1052
|
export const analyzeTarget = analyzeUrl;
|
|
1040
1053
|
export { formatErrorMessage };
|
|
1041
1054
|
export { buildCompromiseSignals, emptyCompromiseSignals } from "./compromiseSignals.js";
|
|
1055
|
+
export { buildObservationLedger } from "./observations.js";
|
|
1042
1056
|
export { buildActionPlan } from "./actionPlan.js";
|
|
1057
|
+
export { scanLiveCertificate } from "./certificate.js";
|
|
1043
1058
|
export { buildExposureBrief } from "./exposureBrief.js";
|
|
1044
1059
|
export { buildVendorExposureBrief } from "./vendorExposure.js";
|
|
1045
1060
|
export { analyzeInfrastructure } from "./infrastructure.js";
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
const HOUR = 60 * 60 * 1000;
|
|
3
|
+
const categoryTtl = {
|
|
4
|
+
transport: HOUR,
|
|
5
|
+
header: HOUR,
|
|
6
|
+
certificate: 6 * HOUR,
|
|
7
|
+
dns: 24 * HOUR,
|
|
8
|
+
email: 24 * HOUR,
|
|
9
|
+
infrastructure: 24 * HOUR,
|
|
10
|
+
technology: 24 * HOUR,
|
|
11
|
+
trust: 24 * HOUR,
|
|
12
|
+
availability: HOUR,
|
|
13
|
+
};
|
|
14
|
+
function kindToken(value) {
|
|
15
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 48) || "unknown";
|
|
16
|
+
}
|
|
17
|
+
function observationId(category, kind, subject, source) {
|
|
18
|
+
const fingerprint = createHash("sha256")
|
|
19
|
+
.update(`${category}\u0000${kind}\u0000${subject.toLowerCase()}\u0000${source}`)
|
|
20
|
+
.digest("hex")
|
|
21
|
+
.slice(0, 20);
|
|
22
|
+
return `obs_${fingerprint}`;
|
|
23
|
+
}
|
|
24
|
+
function evidence(kind, label, observed, source = "observed") {
|
|
25
|
+
return [{
|
|
26
|
+
kind,
|
|
27
|
+
label,
|
|
28
|
+
observed: Array.isArray(observed) ? observed.join(", ") : observed === null ? null : String(observed),
|
|
29
|
+
source,
|
|
30
|
+
}];
|
|
31
|
+
}
|
|
32
|
+
export function buildObservationLedger(result) {
|
|
33
|
+
const generatedAt = result.scannedAt || new Date().toISOString();
|
|
34
|
+
const observedAtMs = new Date(generatedAt).getTime();
|
|
35
|
+
const baseMs = Number.isFinite(observedAtMs) ? observedAtMs : Date.now();
|
|
36
|
+
const observations = [];
|
|
37
|
+
const add = (input) => {
|
|
38
|
+
observations.push({
|
|
39
|
+
...input,
|
|
40
|
+
id: observationId(input.category, input.kind, input.subject, input.source),
|
|
41
|
+
observedAt: generatedAt,
|
|
42
|
+
freshUntil: new Date(baseMs + (input.ttlMs ?? categoryTtl[input.category])).toISOString(),
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
add({
|
|
46
|
+
category: "transport",
|
|
47
|
+
kind: "http.status",
|
|
48
|
+
subject: result.finalUrl,
|
|
49
|
+
status: result.statusCode > 0 ? "observed" : "unavailable",
|
|
50
|
+
value: result.statusCode > 0 ? result.statusCode : null,
|
|
51
|
+
confidence: "high",
|
|
52
|
+
source: "probe",
|
|
53
|
+
evidence: evidence("probe", "HTTP response status", result.statusCode > 0 ? result.statusCode : null),
|
|
54
|
+
});
|
|
55
|
+
for (const header of result.headers) {
|
|
56
|
+
add({
|
|
57
|
+
category: "header",
|
|
58
|
+
kind: `http.header.${header.key.toLowerCase()}`,
|
|
59
|
+
subject: result.finalUrl,
|
|
60
|
+
status: header.status === "missing" ? "missing" : "observed",
|
|
61
|
+
value: header.value,
|
|
62
|
+
confidence: "high",
|
|
63
|
+
source: "header",
|
|
64
|
+
evidence: evidence("header", header.label, header.value),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
const certificate = result.certificate;
|
|
68
|
+
add({
|
|
69
|
+
category: "certificate",
|
|
70
|
+
kind: "tls.certificate.valid",
|
|
71
|
+
subject: result.host,
|
|
72
|
+
status: certificate.available ? "observed" : "unavailable",
|
|
73
|
+
value: certificate.available ? certificate.valid && certificate.authorized : null,
|
|
74
|
+
confidence: "high",
|
|
75
|
+
source: "tls",
|
|
76
|
+
evidence: evidence("tls", "Certificate validity", certificate.available ? certificate.valid && certificate.authorized : null),
|
|
77
|
+
});
|
|
78
|
+
add({
|
|
79
|
+
category: "certificate",
|
|
80
|
+
kind: "tls.certificate.days_remaining",
|
|
81
|
+
subject: result.host,
|
|
82
|
+
status: certificate.daysRemaining === null ? "unavailable" : "observed",
|
|
83
|
+
value: certificate.daysRemaining,
|
|
84
|
+
confidence: "high",
|
|
85
|
+
source: "tls",
|
|
86
|
+
evidence: evidence("tls", "Certificate days remaining", certificate.daysRemaining),
|
|
87
|
+
});
|
|
88
|
+
add({
|
|
89
|
+
category: "transport",
|
|
90
|
+
kind: "tls.protocol",
|
|
91
|
+
subject: result.host,
|
|
92
|
+
status: certificate.protocol ? "observed" : "unavailable",
|
|
93
|
+
value: certificate.protocol,
|
|
94
|
+
confidence: "high",
|
|
95
|
+
source: "tls",
|
|
96
|
+
evidence: evidence("tls", "Negotiated TLS protocol", certificate.protocol),
|
|
97
|
+
});
|
|
98
|
+
const domain = result.domainSecurity;
|
|
99
|
+
add({
|
|
100
|
+
category: "dns",
|
|
101
|
+
kind: "dns.dnssec",
|
|
102
|
+
subject: domain.host,
|
|
103
|
+
status: domain.dnssec.status === "unknown" ? "unavailable" : domain.dnssec.enabled ? "observed" : "missing",
|
|
104
|
+
value: domain.dnssec.status,
|
|
105
|
+
confidence: domain.dnssec.status === "unknown" ? "low" : "high",
|
|
106
|
+
source: "dns",
|
|
107
|
+
evidence: evidence("dns", "DNSSEC status", domain.dnssec.status),
|
|
108
|
+
});
|
|
109
|
+
for (const [kind, policy] of [["email.spf", domain.emailPolicy.spf], ["email.dmarc", domain.emailPolicy.dmarc]]) {
|
|
110
|
+
add({
|
|
111
|
+
category: "email",
|
|
112
|
+
kind,
|
|
113
|
+
subject: domain.host,
|
|
114
|
+
status: policy.status === "missing" ? "missing" : "observed",
|
|
115
|
+
value: policy.status,
|
|
116
|
+
confidence: "high",
|
|
117
|
+
source: "dns",
|
|
118
|
+
evidence: evidence("dns", kind === "email.spf" ? "SPF policy" : "DMARC policy", policy.status),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
add({
|
|
122
|
+
category: "trust",
|
|
123
|
+
kind: "public.security_txt",
|
|
124
|
+
subject: result.host,
|
|
125
|
+
status: result.securityTxt.status === "missing" ? "missing" : "observed",
|
|
126
|
+
value: result.securityTxt.status,
|
|
127
|
+
confidence: "high",
|
|
128
|
+
source: "public_record",
|
|
129
|
+
evidence: evidence("public_record", "security.txt status", result.securityTxt.status),
|
|
130
|
+
});
|
|
131
|
+
for (const provider of result.infrastructure.providers) {
|
|
132
|
+
add({
|
|
133
|
+
category: "infrastructure",
|
|
134
|
+
kind: `infrastructure.provider.${provider.category}.${kindToken(provider.provider)}`,
|
|
135
|
+
subject: result.host,
|
|
136
|
+
status: provider.source === "technology" ? "inferred" : "observed",
|
|
137
|
+
value: provider.provider,
|
|
138
|
+
confidence: provider.confidence,
|
|
139
|
+
source: "infrastructure",
|
|
140
|
+
evidence: evidence(provider.source === "dns" || provider.source === "reverse_dns" ? "dns" : "header", provider.provider, provider.evidence, provider.source === "technology" ? "inferred" : "observed"),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
for (const technology of result.technologies) {
|
|
144
|
+
add({
|
|
145
|
+
category: "technology",
|
|
146
|
+
kind: `technology.${technology.category}.${kindToken(technology.name)}`,
|
|
147
|
+
subject: result.host,
|
|
148
|
+
status: technology.detection === "inferred" ? "inferred" : "observed",
|
|
149
|
+
value: technology.version ? `${technology.name}@${technology.version}` : technology.name,
|
|
150
|
+
confidence: technology.confidence,
|
|
151
|
+
source: "technology",
|
|
152
|
+
evidence: evidence("html", technology.name, technology.evidence, technology.detection),
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
for (const provider of result.wafFingerprint.providers) {
|
|
156
|
+
add({
|
|
157
|
+
category: "infrastructure",
|
|
158
|
+
kind: `infrastructure.waf.${kindToken(provider.name)}`,
|
|
159
|
+
subject: result.host,
|
|
160
|
+
status: provider.detection === "inferred" ? "inferred" : "observed",
|
|
161
|
+
value: provider.name,
|
|
162
|
+
confidence: provider.confidence,
|
|
163
|
+
source: "infrastructure",
|
|
164
|
+
evidence: evidence("header", `${provider.name} WAF`, provider.evidence, provider.detection),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
if (result.assessmentLimitation.limited) {
|
|
168
|
+
add({
|
|
169
|
+
category: "availability",
|
|
170
|
+
kind: "assessment.limitation",
|
|
171
|
+
subject: result.finalUrl,
|
|
172
|
+
status: "unavailable",
|
|
173
|
+
value: result.assessmentLimitation.kind,
|
|
174
|
+
confidence: "high",
|
|
175
|
+
source: "availability",
|
|
176
|
+
evidence: evidence("score_driver", "Assessment limitation", result.assessmentLimitation.detail),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
observations.sort((left, right) => left.id.localeCompare(right.id));
|
|
180
|
+
const byStatus = {
|
|
181
|
+
observed: 0,
|
|
182
|
+
inferred: 0,
|
|
183
|
+
missing: 0,
|
|
184
|
+
unavailable: 0,
|
|
185
|
+
};
|
|
186
|
+
const byCategory = {};
|
|
187
|
+
for (const observation of observations) {
|
|
188
|
+
byStatus[observation.status] += 1;
|
|
189
|
+
byCategory[observation.category] = (byCategory[observation.category] ?? 0) + 1;
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
version: "1.0",
|
|
193
|
+
target: result.finalUrl,
|
|
194
|
+
generatedAt,
|
|
195
|
+
observations,
|
|
196
|
+
summary: {
|
|
197
|
+
total: observations.length,
|
|
198
|
+
byStatus,
|
|
199
|
+
byCategory,
|
|
200
|
+
highConfidence: observations.filter((observation) => observation.confidence === "high").length,
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -67,6 +67,22 @@ export interface CertificateResult {
|
|
|
67
67
|
subjectAltName: string[];
|
|
68
68
|
issues: string[];
|
|
69
69
|
}
|
|
70
|
+
export interface LiveCertificateChainEntry {
|
|
71
|
+
subject: string | null;
|
|
72
|
+
issuer: string | null;
|
|
73
|
+
validFrom: string | null;
|
|
74
|
+
validTo: string | null;
|
|
75
|
+
fingerprint: string | null;
|
|
76
|
+
}
|
|
77
|
+
export interface LiveCertificateResult extends CertificateResult {
|
|
78
|
+
host: string;
|
|
79
|
+
port: number;
|
|
80
|
+
checkedAt: string;
|
|
81
|
+
serialNumber: string | null;
|
|
82
|
+
keyBits: number | null;
|
|
83
|
+
keyType: string | null;
|
|
84
|
+
chain: LiveCertificateChainEntry[];
|
|
85
|
+
}
|
|
70
86
|
export interface RedirectHop {
|
|
71
87
|
url: string;
|
|
72
88
|
status: number;
|
|
@@ -799,6 +815,34 @@ export interface PublicSignalsInfo {
|
|
|
799
815
|
issues: string[];
|
|
800
816
|
strengths: string[];
|
|
801
817
|
}
|
|
818
|
+
export type ObservationCategory = "transport" | "header" | "certificate" | "dns" | "email" | "infrastructure" | "technology" | "trust" | "availability";
|
|
819
|
+
export type ObservationStatus = "observed" | "inferred" | "missing" | "unavailable";
|
|
820
|
+
export type ObservationValue = string | number | boolean | null | string[];
|
|
821
|
+
export interface PostureObservation {
|
|
822
|
+
id: string;
|
|
823
|
+
category: ObservationCategory;
|
|
824
|
+
kind: string;
|
|
825
|
+
subject: string;
|
|
826
|
+
status: ObservationStatus;
|
|
827
|
+
value: ObservationValue;
|
|
828
|
+
confidence: IssueConfidence;
|
|
829
|
+
source: ScanEvidenceKind | "availability" | "technology" | "infrastructure";
|
|
830
|
+
observedAt: string;
|
|
831
|
+
freshUntil: string;
|
|
832
|
+
evidence: ScanEvidenceReference[];
|
|
833
|
+
}
|
|
834
|
+
export interface ObservationLedger {
|
|
835
|
+
version: "1.0";
|
|
836
|
+
target: string;
|
|
837
|
+
generatedAt: string;
|
|
838
|
+
observations: PostureObservation[];
|
|
839
|
+
summary: {
|
|
840
|
+
total: number;
|
|
841
|
+
byStatus: Record<ObservationStatus, number>;
|
|
842
|
+
byCategory: Partial<Record<ObservationCategory, number>>;
|
|
843
|
+
highConfidence: number;
|
|
844
|
+
};
|
|
845
|
+
}
|
|
802
846
|
export interface AnalysisResult {
|
|
803
847
|
inputUrl: string;
|
|
804
848
|
normalizedUrl: string;
|
|
@@ -846,6 +890,7 @@ export interface AnalysisResult {
|
|
|
846
890
|
publicSignals: PublicSignalsInfo;
|
|
847
891
|
wafFingerprint: WafFingerprintInfo;
|
|
848
892
|
scanTiming?: ScanTimingInfo;
|
|
893
|
+
observationLedger?: ObservationLedger;
|
|
849
894
|
}
|
|
850
895
|
export interface AnalyzeTargetOptions {
|
|
851
896
|
includeCertificate?: boolean;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "securl",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Passive external security posture scanner for public URLs and web services.",
|
|
6
6
|
"author": {
|
|
@@ -77,6 +77,14 @@
|
|
|
77
77
|
"./action-plan": {
|
|
78
78
|
"types": "./dist/actionPlan.d.ts",
|
|
79
79
|
"default": "./dist/actionPlan.js"
|
|
80
|
+
},
|
|
81
|
+
"./live-certificate": {
|
|
82
|
+
"types": "./dist/certificate.d.ts",
|
|
83
|
+
"default": "./dist/certificate.js"
|
|
84
|
+
},
|
|
85
|
+
"./observations": {
|
|
86
|
+
"types": "./dist/observations.d.ts",
|
|
87
|
+
"default": "./dist/observations.js"
|
|
80
88
|
}
|
|
81
89
|
},
|
|
82
90
|
"files": [
|