securl 1.10.0 → 1.11.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 +32 -4
- package/README.md +8 -0
- package/RELEASING.md +7 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/observationDrift.d.ts +2 -0
- package/dist/observationDrift.js +152 -0
- package/dist/observationPolicy.d.ts +8 -0
- package/dist/observationPolicy.js +226 -0
- package/dist/types.d.ts +94 -0
- package/package.json +10 -2
package/CHANGELOG.md
CHANGED
|
@@ -6,11 +6,16 @@ The format is based on Keep a Changelog and this package follows Semantic Versio
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [1.11.1] - 2026-06-24
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
- Updated `node-html-parser` to 8.0.3, replacing the retired entity decoder while preserving the existing parsing API and Node 22 runtime floor.
|
|
13
|
+
|
|
14
|
+
## [1.11.0] - 2026-06-20
|
|
15
|
+
|
|
9
16
|
### Added
|
|
10
|
-
- Added
|
|
11
|
-
- Added `
|
|
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
|
-
- Added `exposureBrief` to analysis results and the `securl/exposure-brief` package export for SDK consumers.
|
|
17
|
+
- Added a bounded declarative observation policy engine, maintained baseline policy, validation helper, and `securl/observation-policy` export.
|
|
18
|
+
- Added `diffObservationLedgers()` and `securl/observation-drift` for deterministic observation-level regression and improvement classification.
|
|
14
19
|
|
|
15
20
|
## [1.10.0] - 2026-06-20
|
|
16
21
|
|
|
@@ -18,6 +23,29 @@ The format is based on Keep a Changelog and this package follows Semantic Versio
|
|
|
18
23
|
- Added `buildObservationLedger()` and the `securl/observations` package export for stable, source-aware posture observations.
|
|
19
24
|
- Added `observationLedger` to completed analysis results with deterministic IDs, confidence, status, and freshness metadata.
|
|
20
25
|
|
|
26
|
+
## [1.9.0] - 2026-06-15
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
- Added `scanLiveCertificate()` and the `securl/live-certificate` package export for bounded TLS handshake-only certificate reads.
|
|
30
|
+
- Added certificate chain, protocol, key-strength, and expiry metadata for lightweight certificate clients.
|
|
31
|
+
|
|
32
|
+
## [1.8.0] - 2026-06-15
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
- Added `buildActionPlan()` and the `securl/action-plan` package export for prioritized owner, effort, and impact-ranked remediation actions.
|
|
36
|
+
|
|
37
|
+
## [1.7.0] - 2026-06-15
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
- Added `buildVendorExposureBrief()` for compact vendor and supply-chain exposure summaries covering visible third-party providers, data-flow categories, SRI gaps, priority vendors, and next actions.
|
|
41
|
+
- Added `vendorExposure` to analysis results and the `securl/vendor-exposure` package export for SDK consumers.
|
|
42
|
+
|
|
43
|
+
## [1.6.0] - 2026-06-15
|
|
44
|
+
|
|
45
|
+
### Added
|
|
46
|
+
- 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.
|
|
47
|
+
- Added `exposureBrief` to analysis results and the `securl/exposure-brief` package export for SDK consumers.
|
|
48
|
+
|
|
21
49
|
## [1.5.1] - 2026-06-15
|
|
22
50
|
|
|
23
51
|
### Changed
|
package/README.md
CHANGED
|
@@ -227,6 +227,10 @@ console.log(ledger.summary, ledger.observations);
|
|
|
227
227
|
|
|
228
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
229
|
|
|
230
|
+
Version `1.11.0+` adds `diffObservationLedgers(current, previous)` from `securl/observation-drift` to classify observation-level regressions, improvements, and neutral changes.
|
|
231
|
+
|
|
232
|
+
Use `evaluateObservationPolicy({ ledger, drift, policy })` from `securl/observation-policy` to apply bounded declarative rules. Rules can select an exact observation kind, kind prefix, or category, then assert equality, membership, or numeric thresholds against current observations or changes. `DEFAULT_OBSERVATION_POLICY` provides a maintained baseline for certificate validity/window, HSTS, CSP, DMARC, and critical regressions.
|
|
233
|
+
|
|
230
234
|
### 9. Evidence-backed remediation plans
|
|
231
235
|
|
|
232
236
|
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.
|
|
@@ -339,6 +343,8 @@ Primary exports:
|
|
|
339
343
|
- `buildActionPlan(result)` - turn remediation, score drivers, exposure, and vendor context into prioritized fix actions.
|
|
340
344
|
- `scanLiveCertificate(url)` - perform a TLS handshake-only certificate read for lightweight cert monitoring.
|
|
341
345
|
- `buildObservationLedger(result)` - produce stable source, confidence, status, and freshness-aware posture observations.
|
|
346
|
+
- `diffObservationLedgers(current, previous)` - compare stable observations and classify their operational impact.
|
|
347
|
+
- `evaluateObservationPolicy({ ledger, drift, policy })` - evaluate bounded declarative posture and change rules.
|
|
342
348
|
- `buildPostureDriftReportFromSnapshots(current, previous)` - produce a complete scan-to-scan drift report for monitoring, alerting, and history views.
|
|
343
349
|
- `buildPostureRemediationPlan(result)` - generate prioritized, owner-aware remediation actions from findings and score drivers.
|
|
344
350
|
- `attachIssueEvidence(result)` - add structured evidence references to findings without changing their existing fields.
|
|
@@ -351,6 +357,8 @@ Package subpath exports:
|
|
|
351
357
|
- `securl/action-plan`
|
|
352
358
|
- `securl/live-certificate`
|
|
353
359
|
- `securl/observations`
|
|
360
|
+
- `securl/observation-drift`
|
|
361
|
+
- `securl/observation-policy`
|
|
354
362
|
- `securl/posture-drift`
|
|
355
363
|
- `securl/remediation-plan`
|
|
356
364
|
- `securl/risk-events`
|
package/RELEASING.md
CHANGED
|
@@ -21,14 +21,19 @@ git diff --name-status securl-v$(node -p "require('./packages/core/package.json'
|
|
|
21
21
|
3. Run:
|
|
22
22
|
- `npm run release:core:check`
|
|
23
23
|
4. Review the dry-run tarball contents.
|
|
24
|
-
5. Confirm
|
|
24
|
+
5. Confirm npm Trusted Publishing is configured for this package:
|
|
25
|
+
- provider: GitHub Actions
|
|
26
|
+
- organization: `this-is-securl`
|
|
27
|
+
- repository: `securl`
|
|
28
|
+
- workflow filename: `publish-core-package.yml`
|
|
29
|
+
- allowed action: `npm publish`
|
|
25
30
|
|
|
26
31
|
## Release steps
|
|
27
32
|
|
|
28
33
|
1. Commit the version/changelog update.
|
|
29
34
|
2. Tag the release using `securl-v<version>`, for example `securl-v1.4.1`.
|
|
30
35
|
3. Push the tag.
|
|
31
|
-
4. Let `.github/workflows/publish-core-package.yml` publish the package.
|
|
36
|
+
4. Let `.github/workflows/publish-core-package.yml` publish the package through short-lived npm OIDC credentials.
|
|
32
37
|
|
|
33
38
|
## Post-release
|
|
34
39
|
|
package/dist/index.d.ts
CHANGED
|
@@ -12,6 +12,8 @@ export declare const analyzeTarget: typeof analyzeUrl;
|
|
|
12
12
|
export { formatErrorMessage };
|
|
13
13
|
export { buildCompromiseSignals, emptyCompromiseSignals } from "./compromiseSignals.js";
|
|
14
14
|
export { buildObservationLedger } from "./observations.js";
|
|
15
|
+
export { diffObservationLedgers } from "./observationDrift.js";
|
|
16
|
+
export { DEFAULT_OBSERVATION_POLICY, evaluateObservationPolicy, validateObservationPolicy } from "./observationPolicy.js";
|
|
15
17
|
export { buildActionPlan } from "./actionPlan.js";
|
|
16
18
|
export { scanLiveCertificate } from "./certificate.js";
|
|
17
19
|
export { buildExposureBrief } from "./exposureBrief.js";
|
package/dist/index.js
CHANGED
|
@@ -1053,6 +1053,8 @@ export const analyzeTarget = analyzeUrl;
|
|
|
1053
1053
|
export { formatErrorMessage };
|
|
1054
1054
|
export { buildCompromiseSignals, emptyCompromiseSignals } from "./compromiseSignals.js";
|
|
1055
1055
|
export { buildObservationLedger } from "./observations.js";
|
|
1056
|
+
export { diffObservationLedgers } from "./observationDrift.js";
|
|
1057
|
+
export { DEFAULT_OBSERVATION_POLICY, evaluateObservationPolicy, validateObservationPolicy } from "./observationPolicy.js";
|
|
1056
1058
|
export { buildActionPlan } from "./actionPlan.js";
|
|
1057
1059
|
export { scanLiveCertificate } from "./certificate.js";
|
|
1058
1060
|
export { buildExposureBrief } from "./exposureBrief.js";
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
const STATUS_RANK = {
|
|
3
|
+
unavailable: 0,
|
|
4
|
+
missing: 1,
|
|
5
|
+
inferred: 2,
|
|
6
|
+
observed: 3,
|
|
7
|
+
};
|
|
8
|
+
const CRITICAL_KINDS = new Set([
|
|
9
|
+
"tls.certificate.valid",
|
|
10
|
+
"email.dmarc",
|
|
11
|
+
"http.header.strict-transport-security",
|
|
12
|
+
"http.header.content-security-policy",
|
|
13
|
+
]);
|
|
14
|
+
function equalValue(left, right) {
|
|
15
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
16
|
+
}
|
|
17
|
+
function certificateBand(days) {
|
|
18
|
+
if (days < 0)
|
|
19
|
+
return 0;
|
|
20
|
+
if (days <= 7)
|
|
21
|
+
return 1;
|
|
22
|
+
if (days <= 14)
|
|
23
|
+
return 2;
|
|
24
|
+
if (days <= 30)
|
|
25
|
+
return 3;
|
|
26
|
+
return 4;
|
|
27
|
+
}
|
|
28
|
+
function impactFor(previous, current) {
|
|
29
|
+
if (!previous && current) {
|
|
30
|
+
return current.status === "missing" || current.status === "unavailable" ? "regression" : "change";
|
|
31
|
+
}
|
|
32
|
+
if (previous && !current) {
|
|
33
|
+
return previous.status === "missing" || previous.status === "unavailable" ? "improvement" : "regression";
|
|
34
|
+
}
|
|
35
|
+
if (!previous || !current)
|
|
36
|
+
return "change";
|
|
37
|
+
if (STATUS_RANK[current.status] < STATUS_RANK[previous.status])
|
|
38
|
+
return "regression";
|
|
39
|
+
if (STATUS_RANK[current.status] > STATUS_RANK[previous.status])
|
|
40
|
+
return "improvement";
|
|
41
|
+
if (current.kind === "tls.certificate.days_remaining"
|
|
42
|
+
&& typeof previous.value === "number"
|
|
43
|
+
&& typeof current.value === "number") {
|
|
44
|
+
const previousBand = certificateBand(previous.value);
|
|
45
|
+
const currentBand = certificateBand(current.value);
|
|
46
|
+
if (currentBand < previousBand)
|
|
47
|
+
return "regression";
|
|
48
|
+
if (currentBand > previousBand || current.value > previous.value + 7)
|
|
49
|
+
return "improvement";
|
|
50
|
+
return "change";
|
|
51
|
+
}
|
|
52
|
+
if (current.kind === "tls.certificate.valid"
|
|
53
|
+
&& typeof previous.value === "boolean"
|
|
54
|
+
&& typeof current.value === "boolean") {
|
|
55
|
+
return current.value ? "improvement" : "regression";
|
|
56
|
+
}
|
|
57
|
+
return "change";
|
|
58
|
+
}
|
|
59
|
+
function severityFor(kind, impact, current) {
|
|
60
|
+
if (impact !== "regression")
|
|
61
|
+
return "info";
|
|
62
|
+
if (kind === "tls.certificate.valid" && current?.value === false)
|
|
63
|
+
return "critical";
|
|
64
|
+
if (CRITICAL_KINDS.has(kind) && (current?.status === "missing" || current?.status === "unavailable"))
|
|
65
|
+
return "critical";
|
|
66
|
+
if (kind === "tls.certificate.days_remaining" && typeof current?.value === "number" && current.value <= 14)
|
|
67
|
+
return "critical";
|
|
68
|
+
return "warning";
|
|
69
|
+
}
|
|
70
|
+
function changeId(observationId, type) {
|
|
71
|
+
return `chg_${createHash("sha256").update(`${observationId}\u0000${type}`).digest("hex").slice(0, 20)}`;
|
|
72
|
+
}
|
|
73
|
+
function summaryFor(type, previous, current) {
|
|
74
|
+
const label = current?.kind ?? previous?.kind ?? "observation";
|
|
75
|
+
if (type === "added")
|
|
76
|
+
return `${label} was newly detected.`;
|
|
77
|
+
if (type === "removed")
|
|
78
|
+
return `${label} is no longer detected.`;
|
|
79
|
+
if (type === "status_changed")
|
|
80
|
+
return `${label} changed from ${previous?.status} to ${current?.status}.`;
|
|
81
|
+
if (type === "confidence_changed")
|
|
82
|
+
return `${label} confidence changed from ${previous?.confidence} to ${current?.confidence}.`;
|
|
83
|
+
return `${label} changed from ${JSON.stringify(previous?.value)} to ${JSON.stringify(current?.value)}.`;
|
|
84
|
+
}
|
|
85
|
+
function buildChange(type, previous, current) {
|
|
86
|
+
const observation = current ?? previous;
|
|
87
|
+
if (!observation)
|
|
88
|
+
throw new Error("Observation change requires a current or previous value.");
|
|
89
|
+
const impact = impactFor(previous, current);
|
|
90
|
+
return {
|
|
91
|
+
id: changeId(observation.id, type),
|
|
92
|
+
observationId: observation.id,
|
|
93
|
+
type,
|
|
94
|
+
impact,
|
|
95
|
+
severity: severityFor(observation.kind, impact, current),
|
|
96
|
+
category: observation.category,
|
|
97
|
+
kind: observation.kind,
|
|
98
|
+
subject: observation.subject,
|
|
99
|
+
previous,
|
|
100
|
+
current,
|
|
101
|
+
summary: summaryFor(type, previous, current),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
export function diffObservationLedgers(current, previous) {
|
|
105
|
+
const currentById = new Map(current.observations.map((observation) => [observation.id, observation]));
|
|
106
|
+
const previousById = new Map(previous.observations.map((observation) => [observation.id, observation]));
|
|
107
|
+
const changes = [];
|
|
108
|
+
for (const observation of current.observations) {
|
|
109
|
+
const before = previousById.get(observation.id) ?? null;
|
|
110
|
+
if (!before) {
|
|
111
|
+
changes.push(buildChange("added", null, observation));
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (before.status !== observation.status)
|
|
115
|
+
changes.push(buildChange("status_changed", before, observation));
|
|
116
|
+
if (!equalValue(before.value, observation.value))
|
|
117
|
+
changes.push(buildChange("value_changed", before, observation));
|
|
118
|
+
if (before.confidence !== observation.confidence)
|
|
119
|
+
changes.push(buildChange("confidence_changed", before, observation));
|
|
120
|
+
}
|
|
121
|
+
for (const observation of previous.observations) {
|
|
122
|
+
if (!currentById.has(observation.id))
|
|
123
|
+
changes.push(buildChange("removed", observation, null));
|
|
124
|
+
}
|
|
125
|
+
const severityRank = { critical: 3, warning: 2, info: 1 };
|
|
126
|
+
changes.sort((left, right) => severityRank[right.severity] - severityRank[left.severity] || left.id.localeCompare(right.id));
|
|
127
|
+
const regressions = changes.filter((change) => change.impact === "regression").length;
|
|
128
|
+
const improvements = changes.filter((change) => change.impact === "improvement").length;
|
|
129
|
+
const bySeverity = { info: 0, warning: 0, critical: 0 };
|
|
130
|
+
const byCategory = {};
|
|
131
|
+
for (const change of changes) {
|
|
132
|
+
bySeverity[change.severity] += 1;
|
|
133
|
+
byCategory[change.category] = (byCategory[change.category] ?? 0) + 1;
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
version: "1.0",
|
|
137
|
+
target: current.target,
|
|
138
|
+
comparedAt: current.generatedAt,
|
|
139
|
+
previousObservedAt: previous.generatedAt,
|
|
140
|
+
currentObservedAt: current.generatedAt,
|
|
141
|
+
changes,
|
|
142
|
+
summary: {
|
|
143
|
+
direction: regressions ? "regressed" : improvements ? "improved" : changes.length ? "changed" : "unchanged",
|
|
144
|
+
total: changes.length,
|
|
145
|
+
regressions,
|
|
146
|
+
improvements,
|
|
147
|
+
neutralChanges: changes.length - regressions - improvements,
|
|
148
|
+
bySeverity,
|
|
149
|
+
byCategory,
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ObservationDriftReport, ObservationLedger, ObservationPolicy, ObservationPolicyEvaluation } from "./types.js";
|
|
2
|
+
export declare const DEFAULT_OBSERVATION_POLICY: ObservationPolicy;
|
|
3
|
+
export declare function validateObservationPolicy(value: unknown): ObservationPolicy;
|
|
4
|
+
export declare function evaluateObservationPolicy({ ledger, drift, policy, }: {
|
|
5
|
+
ledger: ObservationLedger;
|
|
6
|
+
drift?: ObservationDriftReport | null;
|
|
7
|
+
policy?: ObservationPolicy;
|
|
8
|
+
}): ObservationPolicyEvaluation;
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
const MAX_RULES = 25;
|
|
3
|
+
const VALID_OPERATORS = new Set(["eq", "neq", "in", "gte", "lte"]);
|
|
4
|
+
const VALID_SEVERITIES = new Set(["info", "warning", "critical"]);
|
|
5
|
+
const VALID_SCOPES = new Set(["observation", "change"]);
|
|
6
|
+
const VALID_FIELDS = new Set(["status", "value", "confidence", "impact", "severity", "type"]);
|
|
7
|
+
const VALID_CATEGORIES = new Set(["transport", "header", "certificate", "dns", "email", "infrastructure", "technology", "trust", "availability"]);
|
|
8
|
+
export const DEFAULT_OBSERVATION_POLICY = {
|
|
9
|
+
id: "securl-baseline-v1",
|
|
10
|
+
name: "SecURL baseline",
|
|
11
|
+
version: "1.0",
|
|
12
|
+
rules: [
|
|
13
|
+
{
|
|
14
|
+
id: "certificate-valid",
|
|
15
|
+
title: "Certificate must remain valid",
|
|
16
|
+
severity: "critical",
|
|
17
|
+
scope: "observation",
|
|
18
|
+
selector: { kind: "tls.certificate.valid" },
|
|
19
|
+
assertion: { field: "value", operator: "eq", value: true },
|
|
20
|
+
requireMatch: true,
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: "certificate-window",
|
|
24
|
+
title: "Certificate must have at least 14 days remaining",
|
|
25
|
+
severity: "critical",
|
|
26
|
+
scope: "observation",
|
|
27
|
+
selector: { kind: "tls.certificate.days_remaining" },
|
|
28
|
+
assertion: { field: "value", operator: "gte", value: 14 },
|
|
29
|
+
requireMatch: true,
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: "hsts-present",
|
|
33
|
+
title: "HSTS must be present",
|
|
34
|
+
severity: "warning",
|
|
35
|
+
scope: "observation",
|
|
36
|
+
selector: { kind: "http.header.strict-transport-security" },
|
|
37
|
+
assertion: { field: "status", operator: "eq", value: "observed" },
|
|
38
|
+
requireMatch: true,
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: "csp-present",
|
|
42
|
+
title: "Content Security Policy must be present",
|
|
43
|
+
severity: "warning",
|
|
44
|
+
scope: "observation",
|
|
45
|
+
selector: { kind: "http.header.content-security-policy" },
|
|
46
|
+
assertion: { field: "status", operator: "eq", value: "observed" },
|
|
47
|
+
requireMatch: true,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: "dmarc-enforced",
|
|
51
|
+
title: "DMARC should be strong or monitored",
|
|
52
|
+
severity: "warning",
|
|
53
|
+
scope: "observation",
|
|
54
|
+
selector: { kind: "email.dmarc" },
|
|
55
|
+
assertion: { field: "value", operator: "in", value: ["strong", "watch"] },
|
|
56
|
+
requireMatch: true,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: "critical-regression",
|
|
60
|
+
title: "Critical observation regressions are not allowed",
|
|
61
|
+
severity: "critical",
|
|
62
|
+
scope: "change",
|
|
63
|
+
selector: {},
|
|
64
|
+
assertion: { field: "severity", operator: "neq", value: "critical" },
|
|
65
|
+
requireMatch: false,
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
};
|
|
69
|
+
function boundedText(value, field, max) {
|
|
70
|
+
if (typeof value !== "string" || !value.trim() || value.length > max) {
|
|
71
|
+
throw new Error(`Observation policy ${field} must be a non-empty string up to ${max} characters.`);
|
|
72
|
+
}
|
|
73
|
+
return value.trim();
|
|
74
|
+
}
|
|
75
|
+
export function validateObservationPolicy(value) {
|
|
76
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
77
|
+
throw new Error("Observation policy must be an object.");
|
|
78
|
+
const input = value;
|
|
79
|
+
if (!Array.isArray(input.rules) || input.rules.length === 0 || input.rules.length > MAX_RULES) {
|
|
80
|
+
throw new Error(`Observation policy must contain between 1 and ${MAX_RULES} rules.`);
|
|
81
|
+
}
|
|
82
|
+
const seen = new Set();
|
|
83
|
+
const rules = input.rules.map((rawRule) => {
|
|
84
|
+
if (!rawRule || typeof rawRule !== "object" || Array.isArray(rawRule))
|
|
85
|
+
throw new Error("Each observation policy rule must be an object.");
|
|
86
|
+
const rule = rawRule;
|
|
87
|
+
const id = boundedText(rule.id, "rule id", 64);
|
|
88
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/i.test(id) || seen.has(id))
|
|
89
|
+
throw new Error(`Observation policy rule id is invalid or duplicated: ${id}.`);
|
|
90
|
+
seen.add(id);
|
|
91
|
+
if (!VALID_SEVERITIES.has(String(rule.severity)))
|
|
92
|
+
throw new Error(`Observation policy rule ${id} has an invalid severity.`);
|
|
93
|
+
if (!VALID_SCOPES.has(String(rule.scope)))
|
|
94
|
+
throw new Error(`Observation policy rule ${id} has an invalid scope.`);
|
|
95
|
+
const selector = rule.selector && typeof rule.selector === "object" && !Array.isArray(rule.selector)
|
|
96
|
+
? rule.selector
|
|
97
|
+
: {};
|
|
98
|
+
const assertion = rule.assertion && typeof rule.assertion === "object" && !Array.isArray(rule.assertion)
|
|
99
|
+
? rule.assertion
|
|
100
|
+
: null;
|
|
101
|
+
if (!assertion || !VALID_FIELDS.has(String(assertion.field)) || !VALID_OPERATORS.has(String(assertion.operator))) {
|
|
102
|
+
throw new Error(`Observation policy rule ${id} has an invalid assertion.`);
|
|
103
|
+
}
|
|
104
|
+
if (selector.category !== undefined && !VALID_CATEGORIES.has(String(selector.category))) {
|
|
105
|
+
throw new Error(`Observation policy rule ${id} has an invalid selector category.`);
|
|
106
|
+
}
|
|
107
|
+
if (rule.scope === "observation" && ["impact", "severity", "type"].includes(String(assertion.field))) {
|
|
108
|
+
throw new Error(`Observation policy rule ${id} uses a change-only field for an observation rule.`);
|
|
109
|
+
}
|
|
110
|
+
if (rule.scope === "change" && ["status", "confidence"].includes(String(assertion.field))) {
|
|
111
|
+
throw new Error(`Observation policy rule ${id} uses an observation-only field for a change rule.`);
|
|
112
|
+
}
|
|
113
|
+
if (assertion.operator === "in" && !Array.isArray(assertion.value))
|
|
114
|
+
throw new Error(`Observation policy rule ${id} requires an array value for in.`);
|
|
115
|
+
if ((assertion.operator === "gte" || assertion.operator === "lte") && typeof assertion.value !== "number") {
|
|
116
|
+
throw new Error(`Observation policy rule ${id} requires a numeric assertion value.`);
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
id,
|
|
120
|
+
title: boundedText(rule.title, `rule ${id} title`, 160),
|
|
121
|
+
...(typeof rule.description === "string" ? { description: rule.description.trim().slice(0, 500) } : {}),
|
|
122
|
+
enabled: rule.enabled !== false,
|
|
123
|
+
severity: rule.severity,
|
|
124
|
+
scope: rule.scope,
|
|
125
|
+
selector: {
|
|
126
|
+
...(typeof selector.kind === "string" ? { kind: selector.kind.slice(0, 160) } : {}),
|
|
127
|
+
...(typeof selector.kindPrefix === "string" ? { kindPrefix: selector.kindPrefix.slice(0, 160) } : {}),
|
|
128
|
+
...(typeof selector.category === "string" ? { category: selector.category } : {}),
|
|
129
|
+
},
|
|
130
|
+
assertion: {
|
|
131
|
+
field: assertion.field,
|
|
132
|
+
operator: assertion.operator,
|
|
133
|
+
value: assertion.value,
|
|
134
|
+
},
|
|
135
|
+
requireMatch: rule.requireMatch === true,
|
|
136
|
+
};
|
|
137
|
+
});
|
|
138
|
+
return {
|
|
139
|
+
id: boundedText(input.id, "id", 64),
|
|
140
|
+
name: boundedText(input.name, "name", 120),
|
|
141
|
+
version: boundedText(input.version, "version", 32),
|
|
142
|
+
rules,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
function matchesSelector(entity, rule) {
|
|
146
|
+
const { selector } = rule;
|
|
147
|
+
return (!selector.kind || entity.kind === selector.kind)
|
|
148
|
+
&& (!selector.kindPrefix || entity.kind.startsWith(selector.kindPrefix))
|
|
149
|
+
&& (!selector.category || entity.category === selector.category);
|
|
150
|
+
}
|
|
151
|
+
function actualValue(entity, field) {
|
|
152
|
+
if (field === "value")
|
|
153
|
+
return "current" in entity ? entity.current?.value ?? null : entity.value;
|
|
154
|
+
return entity[field] ?? null;
|
|
155
|
+
}
|
|
156
|
+
function assertionPasses(actual, rule) {
|
|
157
|
+
const { operator, value } = rule.assertion;
|
|
158
|
+
if (operator === "eq")
|
|
159
|
+
return JSON.stringify(actual) === JSON.stringify(value);
|
|
160
|
+
if (operator === "neq")
|
|
161
|
+
return JSON.stringify(actual) !== JSON.stringify(value);
|
|
162
|
+
if (operator === "in")
|
|
163
|
+
return Array.isArray(value) && value.some((candidate) => JSON.stringify(candidate) === JSON.stringify(actual));
|
|
164
|
+
if (operator === "gte")
|
|
165
|
+
return typeof actual === "number" && typeof value === "number" && actual >= value;
|
|
166
|
+
if (operator === "lte")
|
|
167
|
+
return typeof actual === "number" && typeof value === "number" && actual <= value;
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
function violationId(ruleId, entityId) {
|
|
171
|
+
return `pol_${createHash("sha256").update(`${ruleId}\u0000${entityId}`).digest("hex").slice(0, 20)}`;
|
|
172
|
+
}
|
|
173
|
+
function violationFor(rule, entity) {
|
|
174
|
+
const actual = entity ? actualValue(entity, rule.assertion.field) : null;
|
|
175
|
+
const isChange = entity && "observationId" in entity;
|
|
176
|
+
const entityId = entity?.id ?? "missing";
|
|
177
|
+
return {
|
|
178
|
+
id: violationId(rule.id, entityId),
|
|
179
|
+
ruleId: rule.id,
|
|
180
|
+
title: rule.title,
|
|
181
|
+
severity: rule.severity,
|
|
182
|
+
scope: rule.scope,
|
|
183
|
+
observationId: isChange ? entity.observationId : entity?.id ?? null,
|
|
184
|
+
changeId: isChange ? entity.id : null,
|
|
185
|
+
kind: entity?.kind ?? rule.selector.kind ?? rule.selector.kindPrefix ?? null,
|
|
186
|
+
subject: entity?.subject ?? null,
|
|
187
|
+
expected: rule.assertion,
|
|
188
|
+
actual: actual,
|
|
189
|
+
summary: entity
|
|
190
|
+
? `${rule.title}: ${rule.assertion.field} ${rule.assertion.operator} ${JSON.stringify(rule.assertion.value)} was not satisfied.`
|
|
191
|
+
: `${rule.title}: no matching observation was available.`,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
export function evaluateObservationPolicy({ ledger, drift = null, policy = DEFAULT_OBSERVATION_POLICY, }) {
|
|
195
|
+
const normalized = validateObservationPolicy(policy);
|
|
196
|
+
const violations = [];
|
|
197
|
+
const enabledRules = normalized.rules.filter((rule) => rule.enabled !== false);
|
|
198
|
+
for (const rule of enabledRules) {
|
|
199
|
+
const source = rule.scope === "change" ? drift?.changes ?? [] : ledger.observations;
|
|
200
|
+
const matches = source.filter((entity) => matchesSelector(entity, rule));
|
|
201
|
+
if (!matches.length && rule.requireMatch)
|
|
202
|
+
violations.push(violationFor(rule, null));
|
|
203
|
+
for (const entity of matches) {
|
|
204
|
+
if (!assertionPasses(actualValue(entity, rule.assertion.field), rule))
|
|
205
|
+
violations.push(violationFor(rule, entity));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const bySeverity = { info: 0, warning: 0, critical: 0 };
|
|
209
|
+
for (const violation of violations)
|
|
210
|
+
bySeverity[violation.severity] += 1;
|
|
211
|
+
const highestSeverity = bySeverity.critical ? "critical" : bySeverity.warning ? "warning" : bySeverity.info ? "info" : null;
|
|
212
|
+
return {
|
|
213
|
+
version: "1.0",
|
|
214
|
+
policy: { id: normalized.id, name: normalized.name, version: normalized.version },
|
|
215
|
+
target: ledger.target,
|
|
216
|
+
evaluatedAt: ledger.generatedAt,
|
|
217
|
+
passed: violations.length === 0,
|
|
218
|
+
violations,
|
|
219
|
+
summary: {
|
|
220
|
+
rulesEvaluated: enabledRules.length,
|
|
221
|
+
violations: violations.length,
|
|
222
|
+
bySeverity,
|
|
223
|
+
highestSeverity,
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -843,6 +843,100 @@ export interface ObservationLedger {
|
|
|
843
843
|
highConfidence: number;
|
|
844
844
|
};
|
|
845
845
|
}
|
|
846
|
+
export type ObservationChangeType = "added" | "removed" | "status_changed" | "value_changed" | "confidence_changed";
|
|
847
|
+
export type ObservationChangeImpact = "regression" | "improvement" | "change";
|
|
848
|
+
export type ObservationChangeSeverity = "info" | "warning" | "critical";
|
|
849
|
+
export interface ObservationChange {
|
|
850
|
+
id: string;
|
|
851
|
+
observationId: string;
|
|
852
|
+
type: ObservationChangeType;
|
|
853
|
+
impact: ObservationChangeImpact;
|
|
854
|
+
severity: ObservationChangeSeverity;
|
|
855
|
+
category: ObservationCategory;
|
|
856
|
+
kind: string;
|
|
857
|
+
subject: string;
|
|
858
|
+
previous: PostureObservation | null;
|
|
859
|
+
current: PostureObservation | null;
|
|
860
|
+
summary: string;
|
|
861
|
+
}
|
|
862
|
+
export interface ObservationDriftReport {
|
|
863
|
+
version: "1.0";
|
|
864
|
+
target: string;
|
|
865
|
+
comparedAt: string;
|
|
866
|
+
previousObservedAt: string;
|
|
867
|
+
currentObservedAt: string;
|
|
868
|
+
changes: ObservationChange[];
|
|
869
|
+
summary: {
|
|
870
|
+
direction: "regressed" | "improved" | "changed" | "unchanged";
|
|
871
|
+
total: number;
|
|
872
|
+
regressions: number;
|
|
873
|
+
improvements: number;
|
|
874
|
+
neutralChanges: number;
|
|
875
|
+
bySeverity: Record<ObservationChangeSeverity, number>;
|
|
876
|
+
byCategory: Partial<Record<ObservationCategory, number>>;
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
export type ObservationPolicySeverity = "info" | "warning" | "critical";
|
|
880
|
+
export type ObservationPolicyScope = "observation" | "change";
|
|
881
|
+
export type ObservationPolicyField = "status" | "value" | "confidence" | "impact" | "severity" | "type";
|
|
882
|
+
export type ObservationPolicyOperator = "eq" | "neq" | "in" | "gte" | "lte";
|
|
883
|
+
export interface ObservationPolicyRule {
|
|
884
|
+
id: string;
|
|
885
|
+
title: string;
|
|
886
|
+
description?: string;
|
|
887
|
+
enabled?: boolean;
|
|
888
|
+
severity: ObservationPolicySeverity;
|
|
889
|
+
scope: ObservationPolicyScope;
|
|
890
|
+
selector: {
|
|
891
|
+
kind?: string;
|
|
892
|
+
kindPrefix?: string;
|
|
893
|
+
category?: ObservationCategory;
|
|
894
|
+
};
|
|
895
|
+
assertion: {
|
|
896
|
+
field: ObservationPolicyField;
|
|
897
|
+
operator: ObservationPolicyOperator;
|
|
898
|
+
value: ObservationValue | ObservationChangeImpact | ObservationChangeSeverity | ObservationChangeType;
|
|
899
|
+
};
|
|
900
|
+
requireMatch?: boolean;
|
|
901
|
+
}
|
|
902
|
+
export interface ObservationPolicy {
|
|
903
|
+
id: string;
|
|
904
|
+
name: string;
|
|
905
|
+
version: string;
|
|
906
|
+
rules: ObservationPolicyRule[];
|
|
907
|
+
}
|
|
908
|
+
export interface ObservationPolicyViolation {
|
|
909
|
+
id: string;
|
|
910
|
+
ruleId: string;
|
|
911
|
+
title: string;
|
|
912
|
+
severity: ObservationPolicySeverity;
|
|
913
|
+
scope: ObservationPolicyScope;
|
|
914
|
+
observationId: string | null;
|
|
915
|
+
changeId: string | null;
|
|
916
|
+
kind: string | null;
|
|
917
|
+
subject: string | null;
|
|
918
|
+
expected: ObservationPolicyRule["assertion"];
|
|
919
|
+
actual: ObservationValue;
|
|
920
|
+
summary: string;
|
|
921
|
+
}
|
|
922
|
+
export interface ObservationPolicyEvaluation {
|
|
923
|
+
version: "1.0";
|
|
924
|
+
policy: {
|
|
925
|
+
id: string;
|
|
926
|
+
name: string;
|
|
927
|
+
version: string;
|
|
928
|
+
};
|
|
929
|
+
target: string;
|
|
930
|
+
evaluatedAt: string;
|
|
931
|
+
passed: boolean;
|
|
932
|
+
violations: ObservationPolicyViolation[];
|
|
933
|
+
summary: {
|
|
934
|
+
rulesEvaluated: number;
|
|
935
|
+
violations: number;
|
|
936
|
+
bySeverity: Record<ObservationPolicySeverity, number>;
|
|
937
|
+
highestSeverity: ObservationPolicySeverity | null;
|
|
938
|
+
};
|
|
939
|
+
}
|
|
846
940
|
export interface AnalysisResult {
|
|
847
941
|
inputUrl: string;
|
|
848
942
|
normalizedUrl: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "securl",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Passive external security posture scanner for public URLs and web services.",
|
|
6
6
|
"author": {
|
|
@@ -85,6 +85,14 @@
|
|
|
85
85
|
"./observations": {
|
|
86
86
|
"types": "./dist/observations.d.ts",
|
|
87
87
|
"default": "./dist/observations.js"
|
|
88
|
+
},
|
|
89
|
+
"./observation-drift": {
|
|
90
|
+
"types": "./dist/observationDrift.d.ts",
|
|
91
|
+
"default": "./dist/observationDrift.js"
|
|
92
|
+
},
|
|
93
|
+
"./observation-policy": {
|
|
94
|
+
"types": "./dist/observationPolicy.d.ts",
|
|
95
|
+
"default": "./dist/observationPolicy.js"
|
|
88
96
|
}
|
|
89
97
|
},
|
|
90
98
|
"files": [
|
|
@@ -121,6 +129,6 @@
|
|
|
121
129
|
],
|
|
122
130
|
"license": "MIT",
|
|
123
131
|
"dependencies": {
|
|
124
|
-
"node-html-parser": "^
|
|
132
|
+
"node-html-parser": "^8.0.3"
|
|
125
133
|
}
|
|
126
134
|
}
|