securl 1.9.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 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
@@ -211,7 +211,23 @@ console.log({
211
211
  });
212
212
  ```
213
213
 
214
- ### 6. Evidence-backed remediation plans
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
215
231
 
216
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.
217
233
 
@@ -322,6 +338,7 @@ Primary exports:
322
338
  - `buildPostureDigest(result)` - reduce a full scan result to a compact API/mobile-friendly digest.
323
339
  - `buildActionPlan(result)` - turn remediation, score drivers, exposure, and vendor context into prioritized fix actions.
324
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.
325
342
  - `buildPostureDriftReportFromSnapshots(current, previous)` - produce a complete scan-to-scan drift report for monitoring, alerting, and history views.
326
343
  - `buildPostureRemediationPlan(result)` - generate prioritized, owner-aware remediation actions from findings and score drivers.
327
344
  - `attachIssueEvidence(result)` - add structured evidence references to findings without changing their existing fields.
@@ -333,6 +350,7 @@ Package subpath exports:
333
350
  - `securl/posture-digest`
334
351
  - `securl/action-plan`
335
352
  - `securl/live-certificate`
353
+ - `securl/observations`
336
354
  - `securl/posture-drift`
337
355
  - `securl/remediation-plan`
338
356
  - `securl/risk-events`
package/dist/index.d.ts CHANGED
@@ -11,6 +11,7 @@ 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";
15
16
  export { scanLiveCertificate } from "./certificate.js";
16
17
  export { buildExposureBrief } from "./exposureBrief.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
- return {
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
- return {
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,14 +1040,19 @@ export async function analyzeUrl(input, options = {}) {
1031
1040
  exposureBrief: buildExposureBrief(resultWithRemediation),
1032
1041
  vendorExposure: buildVendorExposureBrief(resultWithRemediation),
1033
1042
  };
1034
- return {
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";
1043
1057
  export { scanLiveCertificate } from "./certificate.js";
1044
1058
  export { buildExposureBrief } from "./exposureBrief.js";
@@ -0,0 +1,2 @@
1
+ import type { AnalysisResult, ObservationLedger } from "./types.js";
2
+ export declare function buildObservationLedger(result: AnalysisResult): ObservationLedger;
@@ -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
@@ -815,6 +815,34 @@ export interface PublicSignalsInfo {
815
815
  issues: string[];
816
816
  strengths: string[];
817
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
+ }
818
846
  export interface AnalysisResult {
819
847
  inputUrl: string;
820
848
  normalizedUrl: string;
@@ -862,6 +890,7 @@ export interface AnalysisResult {
862
890
  publicSignals: PublicSignalsInfo;
863
891
  wafFingerprint: WafFingerprintInfo;
864
892
  scanTiming?: ScanTimingInfo;
893
+ observationLedger?: ObservationLedger;
865
894
  }
866
895
  export interface AnalyzeTargetOptions {
867
896
  includeCertificate?: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "securl",
3
- "version": "1.9.0",
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": {
@@ -81,6 +81,10 @@
81
81
  "./live-certificate": {
82
82
  "types": "./dist/certificate.d.ts",
83
83
  "default": "./dist/certificate.js"
84
+ },
85
+ "./observations": {
86
+ "types": "./dist/observations.d.ts",
87
+ "default": "./dist/observations.js"
84
88
  }
85
89
  },
86
90
  "files": [