@blamejs/core 0.7.45 → 0.7.47
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 +4 -0
- package/index.js +4 -0
- package/lib/framework-error.js +18 -0
- package/lib/guard-all.js +2 -0
- package/lib/guard-mime.js +444 -0
- package/lib/guard-time.js +420 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.7.x
|
|
10
10
|
|
|
11
|
+
- **0.7.47** (2026-05-05) — `b.guardMime` — RFC 6838 media-type identifier-safety primitive (KIND="identifier"). Validates user-supplied media type strings destined for Accept-shape comparison, content-type allowlists, and dispatch routing. Threat catalog: shape malformation (missing `/`, bad type/subtype tokens against RFC 6838 §4.2 restricted-name grammar); parameter validation against the RFC 7231 §3.1.1.1 tchar token grammar (token-only or quoted-string per RFC 7230 §3.2.6); wildcard (`type/subtype` with `*`) outside Accept context refuse; vendor tree (`vnd.*`), personal tree (`prs.*`), and unregistered (`x.*` / `x-*`) namespace audit so operators audit those slots; risky-type refuse list covering executable + script-host content types (`application/x-msdownload`, `application/x-bat`, `application/x-msdos-program`, `application/x-sh`, `application/x-csh`, `application/x-perl`, `application/x-python`, `application/javascript`, `application/x-javascript`, `text/javascript`, `text/x-javascript`, `application/x-shockwave-flash`, `application/x-msi`); BIDI / zero-width / control / null-byte universal refuse. `sanitize` lowercases type/subtype while preserving parameter case (multipart boundary tokens etc. are case-significant). Profiles: `strict` (refuse wildcard + risky-type, audit trees + parameters), `balanced` (audit most things, allow vendor tree), `permissive` (universal-refuse class still refused). Postures: `hipaa` / `pci-dss` / `soc2` strict overlay; `gdpr` balanced overlay. Auto-registers into `b.guardAll` as a STANDALONE_GUARD.
|
|
12
|
+
|
|
13
|
+
- **0.7.46** (2026-05-05) — `b.guardTime` — RFC 3339 / ISO 8601 datetime identifier-safety primitive (KIND="identifier"). Validates user-supplied datetime strings destined for audit timestamps, scheduling, retention windows, query ranges, and cross-system event correlation. Threat catalog: shape malformation against the RFC 3339 §5.6 grammar; year-window overflow (default `[1970, 9999]`); naive datetime (no offset) refuse; non-UTC offset policy (strict requires `Z` / `+00:00`); leap-second `60` field policy (RFC 3339 §5.6 valid but parser-panic prone); excessive fractional precision cap (default 9 digits / nanoseconds); date-only and time-only refuse for full-datetime contexts; structural range violations (month / day-in-month / hour / minute / second); BIDI / zero-width / control / null-byte universal refuse. `sanitize` normalizes a space date/time separator to `T` and uppercases the trailing `z` UTC marker. Profiles: `strict` (refuse all of the above), `balanced` (refuse naive; audit non-UTC + leap + fractional + date/time-only), `permissive` (universal-refuse class still refused). Postures: `hipaa` / `pci-dss` / `soc2` strict overlay; `gdpr` balanced overlay. Auto-registers into `b.guardAll` as a STANDALONE_GUARD.
|
|
14
|
+
|
|
11
15
|
- **0.7.45** (2026-05-05) — `b.guardCidr` — CIDR identifier-safety primitive (KIND="identifier"). Validates user-supplied CIDR notation strings (IPv4 + IPv6) destined for network allowlists, ACLs, security-group rules, and tenant-boundary configuration. Threat catalog: shape malformation, IPv4 octet overflow + leading-zero refuse, IPv6 zero-group ambiguity, mask out-of-range (IPv4 32 / IPv6 128 ceiling), network-address misalignment (host bits set on a non-/32 / non-/128 prefix — common typo class), reserved-range membership covering all the IPv4 RFC 1918 private blocks (`10/8`, `172.16/12`, `192.168/16`) plus loopback `127/8`, link-local `169.254/16`, multicast `224/4`, class-E `240/4`, RFC 5737 documentation `192.0.2/24` + `198.51.100/24` + `203.0.113/24`, benchmarking `198.18/15`, CGNAT `100.64/10`, this-network `0/8`, plus IPv6 loopback `::1`, unspecified `::/128`, ULA `fc00::/7`, link-local `fe80::/10`, multicast `ff00::/8`, documentation `2001:db8::/32`, teredo, deprecated 6to4 `2002::/16`. Includes IPv4-mapped IPv6 dual-stack confusion detection (`::ffff:0:0/96` — CVE-2021-22931 IPv6 variant). Bare-IP-without-mask policy (strict refuses; balanced audits; permissive allows). BIDI / zero-width / control / null-byte universal refuse. Profiles: `strict` / `balanced` / `permissive`. Postures: `hipaa` / `pci-dss` / `soc2` strict overlay; `gdpr` balanced overlay. Auto-registers into `b.guardAll` as a STANDALONE_GUARD.
|
|
12
16
|
|
|
13
17
|
- **0.7.44** (2026-05-05) — `b.guardUuid` — UUID identifier-safety primitive (KIND="identifier"). Validates user-supplied UUID strings per RFC 9562 (May 2024, obsoletes RFC 4122). Threat catalog: shape malformation across the four canonical forms (hyphenated 8-4-4-4-12, hyphenless 32-hex, Microsoft GUID braces `{…}`, `urn:uuid:` prefix); RFC 9562 §4.2 unassigned version digits (only 1-8 are defined); non-RFC 4122 variant bits (only the `10xx` high-bits family is the canonical UUID variant); nil UUID §5.9 / max UUID §5.10 sentinel-leak refuse; format policy enforcement (strict default = `hyphenated-only`); BIDI / zero-width / control / null-byte universal refuse via `lib/codepoint-class.js`. `sanitize` returns canonical lowercase hyphenated form (strips braces / urn prefix). Profiles: `strict` (hyphenated-only, refuse all sentinels and non-canonical forms), `balanced` (accept any form, audit sentinels), `permissive` (universal-refuse class still refused). Postures: `hipaa` / `pci-dss` / `soc2` strict overlay; `gdpr` balanced overlay. Auto-registers into `b.guardAll` as a STANDALONE_GUARD; the adaptive integration harness picks it up automatically via the KIND="identifier" dispatcher.
|
package/index.js
CHANGED
|
@@ -113,6 +113,8 @@ var guardEmail = require("./lib/guard-email");
|
|
|
113
113
|
var guardDomain = require("./lib/guard-domain");
|
|
114
114
|
var guardUuid = require("./lib/guard-uuid");
|
|
115
115
|
var guardCidr = require("./lib/guard-cidr");
|
|
116
|
+
var guardTime = require("./lib/guard-time");
|
|
117
|
+
var guardMime = require("./lib/guard-mime");
|
|
116
118
|
var guardAll = require("./lib/guard-all");
|
|
117
119
|
var ssrfGuard = require("./lib/ssrf-guard");
|
|
118
120
|
var authHeader = require("./lib/auth-header");
|
|
@@ -257,6 +259,8 @@ module.exports = {
|
|
|
257
259
|
guardDomain: guardDomain,
|
|
258
260
|
guardUuid: guardUuid,
|
|
259
261
|
guardCidr: guardCidr,
|
|
262
|
+
guardTime: guardTime,
|
|
263
|
+
guardMime: guardMime,
|
|
260
264
|
guardAll: guardAll,
|
|
261
265
|
ssrfGuard: ssrfGuard,
|
|
262
266
|
authHeader: authHeader,
|
package/lib/framework-error.js
CHANGED
|
@@ -272,6 +272,22 @@ var GuardUuidError = defineClass("GuardUuidError", { alwaysPermane
|
|
|
272
272
|
// IPv6 dual-stack confusion, BIDI / zero-width / control / null-byte
|
|
273
273
|
// universal refuse. alwaysPermanent.
|
|
274
274
|
var GuardCidrError = defineClass("GuardCidrError", { alwaysPermanent: true });
|
|
275
|
+
// GuardTimeError covers RFC 3339 / ISO 8601 datetime identifier
|
|
276
|
+
// violations: shape malformation, year-window overflow (pre-epoch /
|
|
277
|
+
// far-future), naive datetime (no offset), non-UTC offset, leap-second
|
|
278
|
+
// `60` field policy, excessive fractional precision, date-only /
|
|
279
|
+
// time-only refuse, BIDI / zero-width / control / null-byte universal
|
|
280
|
+
// refuse, structural range violations (month / day-in-month / hour /
|
|
281
|
+
// minute / second). alwaysPermanent.
|
|
282
|
+
var GuardTimeError = defineClass("GuardTimeError", { alwaysPermanent: true });
|
|
283
|
+
// GuardMimeError covers RFC 6838 media-type identifier violations:
|
|
284
|
+
// shape malformation (missing `/`, bad type/subtype tokens), parameter
|
|
285
|
+
// injection (multiple params, bad name/value tokens, malformed quoted-
|
|
286
|
+
// string), wildcard `*/*` outside Accept context, vendor / personal /
|
|
287
|
+
// unregistered tree namespaces, risky-type refuse list (executable +
|
|
288
|
+
// script-host content types), BIDI / zero-width / control / null-byte
|
|
289
|
+
// universal refuse. alwaysPermanent.
|
|
290
|
+
var GuardMimeError = defineClass("GuardMimeError", { alwaysPermanent: true });
|
|
275
291
|
// DoraError covers DORA Article 17 incident-reporting workflow errors
|
|
276
292
|
// (classification refusal, report-shape validation, ESA-template
|
|
277
293
|
// generation, audit-chain integration). Permanent — these are
|
|
@@ -334,6 +350,8 @@ module.exports = {
|
|
|
334
350
|
GuardDomainError: GuardDomainError,
|
|
335
351
|
GuardUuidError: GuardUuidError,
|
|
336
352
|
GuardCidrError: GuardCidrError,
|
|
353
|
+
GuardTimeError: GuardTimeError,
|
|
354
|
+
GuardMimeError: GuardMimeError,
|
|
337
355
|
DoraError: DoraError,
|
|
338
356
|
ComplianceError: ComplianceError,
|
|
339
357
|
SmtpPolicyError: SmtpPolicyError,
|
package/lib/guard-all.js
CHANGED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* guard-mime — Media-type identifier-safety primitive (b.guardMime).
|
|
4
|
+
*
|
|
5
|
+
* Validates user-supplied RFC 6838 media type strings destined for
|
|
6
|
+
* Accept-shape comparison, content-type allowlists, and dispatch
|
|
7
|
+
* routing. KIND="identifier" — consumes ctx.identifier (or ctx.mime).
|
|
8
|
+
*
|
|
9
|
+
* Threat catalog:
|
|
10
|
+
* - Shape malformation — not RFC 6838 type/subtype grammar.
|
|
11
|
+
* - Bad token characters — RFC 6838 §4.2 restricts type and subtype
|
|
12
|
+
* to ALPHA / DIGIT / `!#$&-^_.+`. Spaces / quotes / Unicode reject.
|
|
13
|
+
* - Parameter injection — operators sometimes pass through user-
|
|
14
|
+
* supplied parameters (`text/plain; charset=...`); the grammar is
|
|
15
|
+
* permissive enough to smuggle multiple parameters or bare values.
|
|
16
|
+
* - Wildcard `* / *` and `type / *` — only valid in Accept-header
|
|
17
|
+
* context; refused as a content-type at strict.
|
|
18
|
+
* - Vendor tree without operator opt-in (`application/vnd.<vendor>`)
|
|
19
|
+
* — flag at strict so operators audit the vendor namespace.
|
|
20
|
+
* - Personal tree (`application/prs.*`) and unregistered (`x.*`) —
|
|
21
|
+
* same flag class.
|
|
22
|
+
* - Risky types refuse list — `application/x-msdownload`, `.x-bat`,
|
|
23
|
+
* `.x-msdos-program`, `.x-sh`, `.x-csh`, `.javascript`,
|
|
24
|
+
* `.x-javascript` (when handed off to a script-host).
|
|
25
|
+
* - BIDI / zero-width / control / null-byte universal refuse.
|
|
26
|
+
*
|
|
27
|
+
* var rv = b.guardMime.validate("application/json",
|
|
28
|
+
* { profile: "strict" });
|
|
29
|
+
* var safe = b.guardMime.sanitize("Application/JSON; charset=UTF-8",
|
|
30
|
+
* { profile: "balanced" });
|
|
31
|
+
* var g = b.guardMime.gate({ profile: "strict" });
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
var codepointClass = require("./codepoint-class");
|
|
35
|
+
var lazyRequire = require("./lazy-require");
|
|
36
|
+
var gateContract = require("./gate-contract");
|
|
37
|
+
var C = require("./constants");
|
|
38
|
+
var numericBounds = require("./numeric-bounds");
|
|
39
|
+
var { GuardMimeError } = require("./framework-error");
|
|
40
|
+
|
|
41
|
+
var observability = lazyRequire(function () { return require("./observability"); });
|
|
42
|
+
void observability;
|
|
43
|
+
|
|
44
|
+
var _err = GuardMimeError.factory;
|
|
45
|
+
|
|
46
|
+
// RFC 6838 type / subtype grammar. The `restricted-name` allows
|
|
47
|
+
// ALPHA / DIGIT first, then ALPHA / DIGIT / `!#$&-^_.+`. Length cap
|
|
48
|
+
// 127 octets per token.
|
|
49
|
+
var TOKEN_RE = /^[A-Za-z0-9][A-Za-z0-9!#$&\-^_.+]{0,126}$/;
|
|
50
|
+
|
|
51
|
+
// Parameter token (RFC 7231 §3.1.1.1): tchar set.
|
|
52
|
+
var PARAM_TOKEN_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
53
|
+
|
|
54
|
+
// Quoted-string body (between double quotes) per RFC 7230 §3.2.6.
|
|
55
|
+
var QUOTED_STRING_BODY_RE = /^[\t\x20-\x7e]*$/; // allow:raw-byte-literal — printable ASCII range
|
|
56
|
+
|
|
57
|
+
// Risky-type refuse list (operator-supplied scripts handed to a host).
|
|
58
|
+
var RISKY_TYPES = Object.freeze([
|
|
59
|
+
"application/x-msdownload",
|
|
60
|
+
"application/x-bat",
|
|
61
|
+
"application/x-msdos-program",
|
|
62
|
+
"application/x-sh",
|
|
63
|
+
"application/x-csh",
|
|
64
|
+
"application/x-perl",
|
|
65
|
+
"application/x-python",
|
|
66
|
+
"application/javascript",
|
|
67
|
+
"application/x-javascript",
|
|
68
|
+
"text/javascript",
|
|
69
|
+
"text/x-javascript",
|
|
70
|
+
"application/x-shockwave-flash",
|
|
71
|
+
"application/x-msi",
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
// ---- Profile presets ----
|
|
75
|
+
|
|
76
|
+
var PROFILES = Object.freeze({
|
|
77
|
+
"strict": {
|
|
78
|
+
bidiPolicy: "reject",
|
|
79
|
+
controlPolicy: "reject",
|
|
80
|
+
nullBytePolicy: "reject",
|
|
81
|
+
zeroWidthPolicy: "reject",
|
|
82
|
+
wildcardPolicy: "reject",
|
|
83
|
+
vendorTreePolicy: "audit",
|
|
84
|
+
personalTreePolicy: "audit",
|
|
85
|
+
unregisteredTreePolicy: "audit",
|
|
86
|
+
riskyTypePolicy: "reject",
|
|
87
|
+
parameterPolicy: "audit",
|
|
88
|
+
maxBytes: C.BYTES.bytes(255),
|
|
89
|
+
maxRuntimeMs: C.TIME.seconds(2),
|
|
90
|
+
},
|
|
91
|
+
"balanced": {
|
|
92
|
+
bidiPolicy: "reject",
|
|
93
|
+
controlPolicy: "reject",
|
|
94
|
+
nullBytePolicy: "reject",
|
|
95
|
+
zeroWidthPolicy: "reject",
|
|
96
|
+
wildcardPolicy: "audit",
|
|
97
|
+
vendorTreePolicy: "allow",
|
|
98
|
+
personalTreePolicy: "audit",
|
|
99
|
+
unregisteredTreePolicy: "audit",
|
|
100
|
+
riskyTypePolicy: "audit",
|
|
101
|
+
parameterPolicy: "audit",
|
|
102
|
+
maxBytes: C.BYTES.bytes(255),
|
|
103
|
+
maxRuntimeMs: C.TIME.seconds(2),
|
|
104
|
+
},
|
|
105
|
+
"permissive": {
|
|
106
|
+
bidiPolicy: "reject", // BIDI refused at every profile
|
|
107
|
+
controlPolicy: "reject", // controls refused at every profile
|
|
108
|
+
nullBytePolicy: "reject", // null refused at every profile
|
|
109
|
+
zeroWidthPolicy: "reject", // zero-width refused at every profile
|
|
110
|
+
wildcardPolicy: "allow",
|
|
111
|
+
vendorTreePolicy: "allow",
|
|
112
|
+
personalTreePolicy: "allow",
|
|
113
|
+
unregisteredTreePolicy: "allow",
|
|
114
|
+
riskyTypePolicy: "audit",
|
|
115
|
+
parameterPolicy: "allow",
|
|
116
|
+
maxBytes: C.BYTES.bytes(255),
|
|
117
|
+
maxRuntimeMs: C.TIME.seconds(2),
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
var DEFAULTS = Object.freeze(Object.assign({}, PROFILES["strict"], {
|
|
122
|
+
mode: "enforce",
|
|
123
|
+
}));
|
|
124
|
+
|
|
125
|
+
var COMPLIANCE_POSTURES = Object.freeze({
|
|
126
|
+
"hipaa": Object.assign({}, PROFILES["strict"], {
|
|
127
|
+
forensicSnippetBytes: C.BYTES.bytes(128),
|
|
128
|
+
}),
|
|
129
|
+
"pci-dss": Object.assign({}, PROFILES["strict"], {
|
|
130
|
+
forensicSnippetBytes: C.BYTES.bytes(128),
|
|
131
|
+
}),
|
|
132
|
+
"gdpr": Object.assign({}, PROFILES["balanced"], {
|
|
133
|
+
forensicSnippetBytes: C.BYTES.bytes(64),
|
|
134
|
+
}),
|
|
135
|
+
"soc2": Object.assign({}, PROFILES["strict"], {
|
|
136
|
+
forensicSnippetBytes: C.BYTES.bytes(256),
|
|
137
|
+
}),
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
function _resolveOpts(opts) {
|
|
141
|
+
return gateContract.resolveProfileAndPosture(opts, {
|
|
142
|
+
profiles: PROFILES,
|
|
143
|
+
compliancePostures: COMPLIANCE_POSTURES,
|
|
144
|
+
defaults: DEFAULTS,
|
|
145
|
+
errorClass: GuardMimeError,
|
|
146
|
+
errCodePrefix: "mime",
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---- Parser ----
|
|
151
|
+
|
|
152
|
+
function _splitTopLevel(input) {
|
|
153
|
+
// Returns { typeSubtype, params: [{name, value}], errors: [string] }.
|
|
154
|
+
// Splits on `;` outside quoted-strings.
|
|
155
|
+
var parts = [];
|
|
156
|
+
var inQuote = false;
|
|
157
|
+
var start = 0;
|
|
158
|
+
for (var i = 0; i < input.length; i += 1) {
|
|
159
|
+
var c = input.charAt(i);
|
|
160
|
+
if (c === '"' && (i === 0 || input.charAt(i - 1) !== "\\")) inQuote = !inQuote;
|
|
161
|
+
else if (!inQuote && c === ";") {
|
|
162
|
+
parts.push(input.slice(start, i));
|
|
163
|
+
start = i + 1;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
parts.push(input.slice(start));
|
|
167
|
+
parts = parts.map(function (p) { return p.trim(); });
|
|
168
|
+
return parts;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function _detectIssues(input, opts) {
|
|
172
|
+
var issues = [];
|
|
173
|
+
if (typeof input !== "string") {
|
|
174
|
+
return [{ kind: "bad-input", severity: "high",
|
|
175
|
+
ruleId: "mime.bad-input",
|
|
176
|
+
snippet: "mime is not a string" }];
|
|
177
|
+
}
|
|
178
|
+
if (input.length === 0) {
|
|
179
|
+
return [{ kind: "empty", severity: "high",
|
|
180
|
+
ruleId: "mime.empty",
|
|
181
|
+
snippet: "mime is empty" }];
|
|
182
|
+
}
|
|
183
|
+
if (Buffer.byteLength(input, "utf8") > opts.maxBytes) {
|
|
184
|
+
return [{ kind: "mime-cap", severity: "high",
|
|
185
|
+
ruleId: "mime.mime-cap",
|
|
186
|
+
snippet: "mime input exceeds maxBytes " + opts.maxBytes }];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
var charThreats = codepointClass.detectCharThreats(input, opts, "mime");
|
|
190
|
+
for (var ci = 0; ci < charThreats.length; ci += 1) issues.push(charThreats[ci]);
|
|
191
|
+
|
|
192
|
+
var parts = _splitTopLevel(input);
|
|
193
|
+
var typeSubtype = parts[0];
|
|
194
|
+
var paramParts = parts.slice(1).filter(function (p) { return p.length > 0; });
|
|
195
|
+
|
|
196
|
+
// Type/subtype shape.
|
|
197
|
+
var slashAt = typeSubtype.indexOf("/");
|
|
198
|
+
if (slashAt === -1) {
|
|
199
|
+
issues.push({
|
|
200
|
+
kind: "mime-shape", severity: "high",
|
|
201
|
+
ruleId: "mime.mime-shape",
|
|
202
|
+
snippet: "missing `/` between type and subtype",
|
|
203
|
+
});
|
|
204
|
+
return issues;
|
|
205
|
+
}
|
|
206
|
+
var type = typeSubtype.slice(0, slashAt);
|
|
207
|
+
var subtype = typeSubtype.slice(slashAt + 1);
|
|
208
|
+
|
|
209
|
+
// Wildcard policy.
|
|
210
|
+
if ((type === "*" || subtype === "*") &&
|
|
211
|
+
opts.wildcardPolicy !== "allow") {
|
|
212
|
+
issues.push({
|
|
213
|
+
kind: "wildcard",
|
|
214
|
+
severity: opts.wildcardPolicy === "reject" ? "high" : "warn",
|
|
215
|
+
ruleId: "mime.wildcard",
|
|
216
|
+
snippet: "wildcard `" + typeSubtype + "` only valid in Accept " +
|
|
217
|
+
"headers; refused as content-type at strict",
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Token validation per type / subtype.
|
|
222
|
+
if (type !== "*") {
|
|
223
|
+
if (!TOKEN_RE.test(type)) { // allow:regex-no-length-cap — input bounded by maxBytes
|
|
224
|
+
issues.push({
|
|
225
|
+
kind: "type-shape", severity: "high",
|
|
226
|
+
ruleId: "mime.type-shape",
|
|
227
|
+
snippet: "type `" + type + "` is not a valid RFC 6838 " +
|
|
228
|
+
"restricted-name token",
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (subtype !== "*") {
|
|
233
|
+
if (!TOKEN_RE.test(subtype)) { // allow:regex-no-length-cap — input bounded by maxBytes
|
|
234
|
+
issues.push({
|
|
235
|
+
kind: "subtype-shape", severity: "high",
|
|
236
|
+
ruleId: "mime.subtype-shape",
|
|
237
|
+
snippet: "subtype `" + subtype + "` is not a valid RFC 6838 " +
|
|
238
|
+
"restricted-name token",
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Tree-prefix detection.
|
|
244
|
+
var subtypeLower = subtype.toLowerCase();
|
|
245
|
+
if (subtypeLower.indexOf("vnd.") === 0 &&
|
|
246
|
+
opts.vendorTreePolicy !== "allow") {
|
|
247
|
+
issues.push({
|
|
248
|
+
kind: "vendor-tree",
|
|
249
|
+
severity: opts.vendorTreePolicy === "reject" ? "high" : "warn",
|
|
250
|
+
ruleId: "mime.vendor-tree",
|
|
251
|
+
snippet: "subtype `" + subtype + "` is in the vendor tree " +
|
|
252
|
+
"(`vnd.*`); audit the vendor namespace",
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
if (subtypeLower.indexOf("prs.") === 0 &&
|
|
256
|
+
opts.personalTreePolicy !== "allow") {
|
|
257
|
+
issues.push({
|
|
258
|
+
kind: "personal-tree",
|
|
259
|
+
severity: opts.personalTreePolicy === "reject" ? "high" : "warn",
|
|
260
|
+
ruleId: "mime.personal-tree",
|
|
261
|
+
snippet: "subtype `" + subtype + "` is in the personal tree " +
|
|
262
|
+
"(`prs.*`)",
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
if ((subtypeLower.indexOf("x.") === 0 || subtypeLower.indexOf("x-") === 0) &&
|
|
266
|
+
opts.unregisteredTreePolicy !== "allow") {
|
|
267
|
+
issues.push({
|
|
268
|
+
kind: "unregistered-tree",
|
|
269
|
+
severity: opts.unregisteredTreePolicy === "reject" ? "high" : "warn",
|
|
270
|
+
ruleId: "mime.unregistered-tree",
|
|
271
|
+
snippet: "subtype `" + subtype + "` is in the unregistered tree " +
|
|
272
|
+
"(`x.*` / `x-*`)",
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Risky-type refuse list (use lowercased canonical compare).
|
|
277
|
+
var canonical = (type + "/" + subtype).toLowerCase();
|
|
278
|
+
if (RISKY_TYPES.indexOf(canonical) !== -1 &&
|
|
279
|
+
opts.riskyTypePolicy !== "allow") {
|
|
280
|
+
issues.push({
|
|
281
|
+
kind: "risky-type",
|
|
282
|
+
severity: opts.riskyTypePolicy === "reject" ? "high" : "warn",
|
|
283
|
+
ruleId: "mime.risky-type",
|
|
284
|
+
snippet: "media type `" + canonical + "` is on the risky-type " +
|
|
285
|
+
"refuse list (executable / script-host class)",
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Parameter validation (all params at once — covers injection class).
|
|
290
|
+
if (paramParts.length > 0 && opts.parameterPolicy !== "allow") {
|
|
291
|
+
for (var pi = 0; pi < paramParts.length; pi += 1) {
|
|
292
|
+
var pp = paramParts[pi];
|
|
293
|
+
var eqAt = pp.indexOf("=");
|
|
294
|
+
if (eqAt === -1) {
|
|
295
|
+
issues.push({
|
|
296
|
+
kind: "param-shape",
|
|
297
|
+
severity: opts.parameterPolicy === "reject" ? "high" : "warn",
|
|
298
|
+
ruleId: "mime.param-shape",
|
|
299
|
+
snippet: "parameter `" + pp + "` missing `=` value separator",
|
|
300
|
+
});
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
var pname = pp.slice(0, eqAt).trim();
|
|
304
|
+
var pvalue = pp.slice(eqAt + 1).trim();
|
|
305
|
+
if (!PARAM_TOKEN_RE.test(pname)) { // allow:regex-no-length-cap — name bounded by parameter length within maxBytes
|
|
306
|
+
issues.push({
|
|
307
|
+
kind: "param-name",
|
|
308
|
+
severity: opts.parameterPolicy === "reject" ? "high" : "warn",
|
|
309
|
+
ruleId: "mime.param-name",
|
|
310
|
+
snippet: "parameter name `" + pname + "` is not a valid " +
|
|
311
|
+
"RFC 7231 §3.1.1.1 tchar token",
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
// Value: either a token or a quoted-string.
|
|
315
|
+
if (pvalue.length === 0) {
|
|
316
|
+
issues.push({
|
|
317
|
+
kind: "param-value-empty",
|
|
318
|
+
severity: opts.parameterPolicy === "reject" ? "high" : "warn",
|
|
319
|
+
ruleId: "mime.param-value-empty",
|
|
320
|
+
snippet: "parameter `" + pname + "` has empty value",
|
|
321
|
+
});
|
|
322
|
+
} else if (pvalue.charAt(0) === '"' &&
|
|
323
|
+
pvalue.charAt(pvalue.length - 1) === '"') {
|
|
324
|
+
var inner = pvalue.slice(1, -1);
|
|
325
|
+
if (!QUOTED_STRING_BODY_RE.test(inner)) { // allow:regex-no-length-cap — value bounded within maxBytes
|
|
326
|
+
issues.push({
|
|
327
|
+
kind: "param-value-shape",
|
|
328
|
+
severity: opts.parameterPolicy === "reject" ? "high" : "warn",
|
|
329
|
+
ruleId: "mime.param-value-shape",
|
|
330
|
+
snippet: "parameter `" + pname + "` quoted-string contains " +
|
|
331
|
+
"non-printable bytes (RFC 7230 §3.2.6)",
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
} else if (!PARAM_TOKEN_RE.test(pvalue)) { // allow:regex-no-length-cap — value bounded within maxBytes
|
|
335
|
+
issues.push({
|
|
336
|
+
kind: "param-value-shape",
|
|
337
|
+
severity: opts.parameterPolicy === "reject" ? "high" : "warn",
|
|
338
|
+
ruleId: "mime.param-value-shape",
|
|
339
|
+
snippet: "parameter `" + pname + "` value `" + pvalue + "` " +
|
|
340
|
+
"is not a valid token or quoted-string",
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return issues;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function validate(input, opts) {
|
|
350
|
+
opts = _resolveOpts(opts);
|
|
351
|
+
numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
|
|
352
|
+
["maxBytes"],
|
|
353
|
+
"guardMime.validate", GuardMimeError, "mime.bad-opt");
|
|
354
|
+
if (typeof input !== "string") {
|
|
355
|
+
return {
|
|
356
|
+
ok: false,
|
|
357
|
+
issues: [{ kind: "bad-input", severity: "high",
|
|
358
|
+
ruleId: "mime.bad-input",
|
|
359
|
+
snippet: "mime is not a string" }],
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
return gateContract.aggregateIssues(_detectIssues(input, opts));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function sanitize(input, opts) {
|
|
366
|
+
opts = _resolveOpts(opts);
|
|
367
|
+
if (typeof input !== "string") {
|
|
368
|
+
throw _err("mime.bad-input", "sanitize requires string input");
|
|
369
|
+
}
|
|
370
|
+
var issues = _detectIssues(input, opts);
|
|
371
|
+
for (var i = 0; i < issues.length; i += 1) {
|
|
372
|
+
if (issues[i].severity === "critical" || issues[i].severity === "high") {
|
|
373
|
+
throw _err(issues[i].ruleId || "mime.refused",
|
|
374
|
+
"guardMime.sanitize: " + issues[i].snippet);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
// Normalize: lowercase the type/subtype; preserve parameter case
|
|
378
|
+
// because some parameter values are case-significant (e.g. boundary
|
|
379
|
+
// tokens in multipart/form-data).
|
|
380
|
+
var parts = _splitTopLevel(input);
|
|
381
|
+
var canonical = parts[0].toLowerCase();
|
|
382
|
+
return parts.slice(1).reduce(function (acc, p) {
|
|
383
|
+
return acc + "; " + p;
|
|
384
|
+
}, canonical);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function gate(opts) {
|
|
388
|
+
opts = _resolveOpts(opts);
|
|
389
|
+
return gateContract.buildGuardGate(
|
|
390
|
+
opts.name || "guardMime:" + (opts.profile || "default"),
|
|
391
|
+
opts,
|
|
392
|
+
async function (ctx) {
|
|
393
|
+
var identifier = ctx && (ctx.identifier || ctx.mime || "");
|
|
394
|
+
if (!identifier) return { ok: true, action: "serve" };
|
|
395
|
+
var rv = validate(identifier, opts);
|
|
396
|
+
if (rv.issues.length === 0) return { ok: true, action: "serve" };
|
|
397
|
+
var hasCritical = rv.issues.some(function (i) {
|
|
398
|
+
return i.severity === "critical";
|
|
399
|
+
});
|
|
400
|
+
var hasHigh = rv.issues.some(function (i) {
|
|
401
|
+
return i.severity === "high";
|
|
402
|
+
});
|
|
403
|
+
if (!hasCritical && !hasHigh) {
|
|
404
|
+
return { ok: true, action: "audit-only", issues: rv.issues };
|
|
405
|
+
}
|
|
406
|
+
return { ok: false, action: "refuse", issues: rv.issues };
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
var buildProfile = gateContract.makeProfileBuilder(PROFILES);
|
|
411
|
+
|
|
412
|
+
function compliancePosture(name) {
|
|
413
|
+
return gateContract.lookupCompliancePosture(name, COMPLIANCE_POSTURES,
|
|
414
|
+
_err, "mime");
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
var _mimeRulePacks = gateContract.makeRulePackLoader(GuardMimeError, "mime");
|
|
418
|
+
var loadRulePack = _mimeRulePacks.load;
|
|
419
|
+
|
|
420
|
+
module.exports = {
|
|
421
|
+
// ---- guard-* family registry exports ----
|
|
422
|
+
NAME: "mime",
|
|
423
|
+
KIND: "identifier",
|
|
424
|
+
INTEGRATION_FIXTURES: Object.freeze({
|
|
425
|
+
kind: "identifier",
|
|
426
|
+
benignBytes: Buffer.from("application/json", "utf8"),
|
|
427
|
+
hostileBytes: Buffer.from("application/x-msdownload", "utf8"),
|
|
428
|
+
benignIdentifier: "application/json",
|
|
429
|
+
// Hostile: risky-type — refused at strict (executable script-host
|
|
430
|
+
// class).
|
|
431
|
+
hostileIdentifier: "application/x-msdownload",
|
|
432
|
+
}),
|
|
433
|
+
// ---- primitive surface ----
|
|
434
|
+
validate: validate,
|
|
435
|
+
sanitize: sanitize,
|
|
436
|
+
gate: gate,
|
|
437
|
+
buildProfile: buildProfile,
|
|
438
|
+
compliancePosture: compliancePosture,
|
|
439
|
+
loadRulePack: loadRulePack,
|
|
440
|
+
PROFILES: PROFILES,
|
|
441
|
+
DEFAULTS: DEFAULTS,
|
|
442
|
+
COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
|
|
443
|
+
GuardMimeError: GuardMimeError,
|
|
444
|
+
};
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* guard-time — RFC 3339 / ISO 8601 datetime identifier-safety primitive
|
|
4
|
+
* (b.guardTime).
|
|
5
|
+
*
|
|
6
|
+
* Validates user-supplied datetime strings destined for audit
|
|
7
|
+
* timestamps, scheduling, retention windows, query ranges, and
|
|
8
|
+
* cross-system event correlation. KIND="identifier" — consumes
|
|
9
|
+
* ctx.identifier (or ctx.timestamp).
|
|
10
|
+
*
|
|
11
|
+
* Threat catalog:
|
|
12
|
+
* - Shape malformation — not RFC 3339 / ISO 8601 datetime.
|
|
13
|
+
* - Pre-epoch / far-future — year before 1970 or after the
|
|
14
|
+
* operator's far-future ceiling (default 9999); often a parsing
|
|
15
|
+
* bug or sentinel-leak shape.
|
|
16
|
+
* - Naive datetime (no offset) — strict refuses; downstream
|
|
17
|
+
* interpretation depends on local timezone, breaks
|
|
18
|
+
* cross-region equality.
|
|
19
|
+
* - Non-UTC offset — strict accepts only `Z` / `+00:00`; balanced
|
|
20
|
+
* accepts any offset; permissive allows naive too.
|
|
21
|
+
* - Leap-second `60` in seconds field — RFC 3339 §5.6 explicitly
|
|
22
|
+
* valid (`23:59:60Z` is a real wall-clock time); most parsers
|
|
23
|
+
* panic. Flagged-by-default with operator policy.
|
|
24
|
+
* - Excessive fractional precision — RFC 3339 allows any digits
|
|
25
|
+
* after the dot but every consuming system has a cap; flag > 9
|
|
26
|
+
* fractional digits.
|
|
27
|
+
* - Date-only / time-only — refused for full-datetime contexts.
|
|
28
|
+
* - Whitespace / control / null-byte / BIDI universal refuse.
|
|
29
|
+
*
|
|
30
|
+
* var rv = b.guardTime.validate("2026-05-05T12:34:56Z",
|
|
31
|
+
* { profile: "strict" });
|
|
32
|
+
* var safe = b.guardTime.sanitize("2026-05-05T12:34:56.123+05:30",
|
|
33
|
+
* { profile: "balanced" });
|
|
34
|
+
* var g = b.guardTime.gate({ profile: "strict" });
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
var codepointClass = require("./codepoint-class");
|
|
38
|
+
var lazyRequire = require("./lazy-require");
|
|
39
|
+
var gateContract = require("./gate-contract");
|
|
40
|
+
var C = require("./constants");
|
|
41
|
+
var numericBounds = require("./numeric-bounds");
|
|
42
|
+
var { GuardTimeError } = require("./framework-error");
|
|
43
|
+
|
|
44
|
+
var observability = lazyRequire(function () { return require("./observability"); });
|
|
45
|
+
void observability;
|
|
46
|
+
|
|
47
|
+
var _err = GuardTimeError.factory;
|
|
48
|
+
|
|
49
|
+
// RFC 3339 §5.6 full-date + full-time grammar — anchored.
|
|
50
|
+
//
|
|
51
|
+
// Capture groups:
|
|
52
|
+
// 1: year (4 digits) 2: month (2) 3: day (2)
|
|
53
|
+
// 4: hour (2) 5: minute (2) 6: second (2; allows 60 for leap-second)
|
|
54
|
+
// 7: fractional incl. dot (optional) 8: offset (Z or +HH:MM/-HH:MM)
|
|
55
|
+
var RFC3339_RE = /^(\d{4})-(\d{2})-(\d{2})[Tt ](\d{2}):(\d{2}):(\d{2})(\.\d+)?([Zz]|[+-]\d{2}:\d{2})?$/;
|
|
56
|
+
|
|
57
|
+
var DEFAULT_MIN_YEAR = 1970; // allow:raw-byte-literal — Unix epoch year
|
|
58
|
+
var DEFAULT_MAX_YEAR = 9999; // allow:raw-byte-literal — RFC 3339 4-digit year ceiling
|
|
59
|
+
var MAX_FRACTIONAL_DIGITS = 9; // allow:raw-byte-literal — nanosecond precision cap
|
|
60
|
+
|
|
61
|
+
// ---- Profile presets ----
|
|
62
|
+
|
|
63
|
+
var PROFILES = Object.freeze({
|
|
64
|
+
"strict": {
|
|
65
|
+
bidiPolicy: "reject",
|
|
66
|
+
controlPolicy: "reject",
|
|
67
|
+
nullBytePolicy: "reject",
|
|
68
|
+
zeroWidthPolicy: "reject",
|
|
69
|
+
naiveDatetimePolicy: "reject",
|
|
70
|
+
nonUtcOffsetPolicy: "reject",
|
|
71
|
+
leapSecondPolicy: "reject",
|
|
72
|
+
fractionalDigitsPolicy: "reject",
|
|
73
|
+
dateOnlyPolicy: "reject",
|
|
74
|
+
timeOnlyPolicy: "reject",
|
|
75
|
+
minYear: DEFAULT_MIN_YEAR,
|
|
76
|
+
maxYear: DEFAULT_MAX_YEAR,
|
|
77
|
+
maxFractionalDigits: MAX_FRACTIONAL_DIGITS,
|
|
78
|
+
maxBytes: C.BYTES.bytes(64),
|
|
79
|
+
maxRuntimeMs: C.TIME.seconds(2),
|
|
80
|
+
},
|
|
81
|
+
"balanced": {
|
|
82
|
+
bidiPolicy: "reject",
|
|
83
|
+
controlPolicy: "reject",
|
|
84
|
+
nullBytePolicy: "reject",
|
|
85
|
+
zeroWidthPolicy: "reject",
|
|
86
|
+
naiveDatetimePolicy: "reject",
|
|
87
|
+
nonUtcOffsetPolicy: "audit",
|
|
88
|
+
leapSecondPolicy: "audit",
|
|
89
|
+
fractionalDigitsPolicy: "audit",
|
|
90
|
+
dateOnlyPolicy: "audit",
|
|
91
|
+
timeOnlyPolicy: "audit",
|
|
92
|
+
minYear: DEFAULT_MIN_YEAR,
|
|
93
|
+
maxYear: DEFAULT_MAX_YEAR,
|
|
94
|
+
maxFractionalDigits: MAX_FRACTIONAL_DIGITS,
|
|
95
|
+
maxBytes: C.BYTES.bytes(64),
|
|
96
|
+
maxRuntimeMs: C.TIME.seconds(2),
|
|
97
|
+
},
|
|
98
|
+
"permissive": {
|
|
99
|
+
bidiPolicy: "reject", // BIDI refused at every profile
|
|
100
|
+
controlPolicy: "reject", // controls refused at every profile
|
|
101
|
+
nullBytePolicy: "reject", // null refused at every profile
|
|
102
|
+
zeroWidthPolicy: "reject", // zero-width refused at every profile
|
|
103
|
+
naiveDatetimePolicy: "audit",
|
|
104
|
+
nonUtcOffsetPolicy: "allow",
|
|
105
|
+
leapSecondPolicy: "allow",
|
|
106
|
+
fractionalDigitsPolicy: "allow",
|
|
107
|
+
dateOnlyPolicy: "allow",
|
|
108
|
+
timeOnlyPolicy: "allow",
|
|
109
|
+
minYear: DEFAULT_MIN_YEAR,
|
|
110
|
+
maxYear: DEFAULT_MAX_YEAR,
|
|
111
|
+
maxFractionalDigits: MAX_FRACTIONAL_DIGITS,
|
|
112
|
+
maxBytes: C.BYTES.bytes(64),
|
|
113
|
+
maxRuntimeMs: C.TIME.seconds(2),
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
var DEFAULTS = Object.freeze(Object.assign({}, PROFILES["strict"], {
|
|
118
|
+
mode: "enforce",
|
|
119
|
+
}));
|
|
120
|
+
|
|
121
|
+
var COMPLIANCE_POSTURES = Object.freeze({
|
|
122
|
+
"hipaa": Object.assign({}, PROFILES["strict"], {
|
|
123
|
+
forensicSnippetBytes: C.BYTES.bytes(128),
|
|
124
|
+
}),
|
|
125
|
+
"pci-dss": Object.assign({}, PROFILES["strict"], {
|
|
126
|
+
forensicSnippetBytes: C.BYTES.bytes(128),
|
|
127
|
+
}),
|
|
128
|
+
"gdpr": Object.assign({}, PROFILES["balanced"], {
|
|
129
|
+
forensicSnippetBytes: C.BYTES.bytes(64),
|
|
130
|
+
}),
|
|
131
|
+
"soc2": Object.assign({}, PROFILES["strict"], {
|
|
132
|
+
forensicSnippetBytes: C.BYTES.bytes(256),
|
|
133
|
+
}),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
function _resolveOpts(opts) {
|
|
137
|
+
return gateContract.resolveProfileAndPosture(opts, {
|
|
138
|
+
profiles: PROFILES,
|
|
139
|
+
compliancePostures: COMPLIANCE_POSTURES,
|
|
140
|
+
defaults: DEFAULTS,
|
|
141
|
+
errorClass: GuardTimeError,
|
|
142
|
+
errCodePrefix: "time",
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---- Detection ----
|
|
147
|
+
|
|
148
|
+
function _detectIssues(input, opts) {
|
|
149
|
+
var issues = [];
|
|
150
|
+
if (typeof input !== "string") {
|
|
151
|
+
return [{ kind: "bad-input", severity: "high",
|
|
152
|
+
ruleId: "time.bad-input",
|
|
153
|
+
snippet: "time is not a string" }];
|
|
154
|
+
}
|
|
155
|
+
if (input.length === 0) {
|
|
156
|
+
return [{ kind: "empty", severity: "high",
|
|
157
|
+
ruleId: "time.empty",
|
|
158
|
+
snippet: "time is empty" }];
|
|
159
|
+
}
|
|
160
|
+
if (Buffer.byteLength(input, "utf8") > opts.maxBytes) {
|
|
161
|
+
return [{ kind: "time-cap", severity: "high",
|
|
162
|
+
ruleId: "time.time-cap",
|
|
163
|
+
snippet: "time input exceeds maxBytes " + opts.maxBytes }];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
var charThreats = codepointClass.detectCharThreats(input, opts, "time");
|
|
167
|
+
for (var ci = 0; ci < charThreats.length; ci += 1) issues.push(charThreats[ci]);
|
|
168
|
+
|
|
169
|
+
// Date-only / time-only quick checks BEFORE the full RFC 3339 regex
|
|
170
|
+
// so the operator gets a more actionable diagnosis.
|
|
171
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(input)) { // allow:regex-no-length-cap — input bounded by maxBytes; allow:duplicate-regex — same RFC 3339 full-date shape used by safe-json + safe-schema; not consolidatable across module boundaries
|
|
172
|
+
if (opts.dateOnlyPolicy !== "allow") {
|
|
173
|
+
issues.push({
|
|
174
|
+
kind: "date-only",
|
|
175
|
+
severity: opts.dateOnlyPolicy === "reject" ? "high" : "warn",
|
|
176
|
+
ruleId: "time.date-only",
|
|
177
|
+
snippet: "input is RFC 3339 full-date only — full datetime " +
|
|
178
|
+
"(date + time + offset) required",
|
|
179
|
+
});
|
|
180
|
+
return issues;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (/^\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/.test(input)) { // allow:regex-no-length-cap — input bounded by maxBytes
|
|
184
|
+
if (opts.timeOnlyPolicy !== "allow") {
|
|
185
|
+
issues.push({
|
|
186
|
+
kind: "time-only",
|
|
187
|
+
severity: opts.timeOnlyPolicy === "reject" ? "high" : "warn",
|
|
188
|
+
ruleId: "time.time-only",
|
|
189
|
+
snippet: "input is RFC 3339 partial-time only — full datetime " +
|
|
190
|
+
"(date + time + offset) required",
|
|
191
|
+
});
|
|
192
|
+
return issues;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
var match = input.match(RFC3339_RE); // allow:regex-no-length-cap — input bounded by maxBytes
|
|
197
|
+
if (!match) {
|
|
198
|
+
issues.push({
|
|
199
|
+
kind: "datetime-shape", severity: "high",
|
|
200
|
+
ruleId: "time.datetime-shape",
|
|
201
|
+
snippet: "input does not match RFC 3339 §5.6 date-time grammar",
|
|
202
|
+
});
|
|
203
|
+
return issues;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
var year = parseInt(match[1], 10); // allow:raw-byte-literal — base-10 radix
|
|
207
|
+
var month = parseInt(match[2], 10); // allow:raw-byte-literal — base-10 radix
|
|
208
|
+
var day = parseInt(match[3], 10); // allow:raw-byte-literal — base-10 radix
|
|
209
|
+
var hour = parseInt(match[4], 10); // allow:raw-byte-literal — base-10 radix
|
|
210
|
+
var minute = parseInt(match[5], 10); // allow:raw-byte-literal — base-10 radix
|
|
211
|
+
var second = parseInt(match[6], 10); // allow:raw-byte-literal — base-10 radix
|
|
212
|
+
var fractional = match[7] || "";
|
|
213
|
+
var offset = match[8];
|
|
214
|
+
|
|
215
|
+
// Year window.
|
|
216
|
+
if (year < opts.minYear || year > opts.maxYear) {
|
|
217
|
+
issues.push({
|
|
218
|
+
kind: "year-window", severity: "high",
|
|
219
|
+
ruleId: "time.year-window",
|
|
220
|
+
snippet: "year " + year + " outside operator window [" +
|
|
221
|
+
opts.minYear + ", " + opts.maxYear + "]",
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Month / day / hour / minute structural ranges.
|
|
226
|
+
if (month < 1 || month > 12) { // allow:raw-byte-literal — month range
|
|
227
|
+
issues.push({
|
|
228
|
+
kind: "month-range", severity: "high",
|
|
229
|
+
ruleId: "time.month-range",
|
|
230
|
+
snippet: "month " + month + " outside [1, 12]",
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
if (day < 1 || day > 31) { // allow:raw-byte-literal — day-of-month upper bound
|
|
234
|
+
issues.push({
|
|
235
|
+
kind: "day-range", severity: "high",
|
|
236
|
+
ruleId: "time.day-range",
|
|
237
|
+
snippet: "day " + day + " outside [1, 31]",
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
if (hour > 23) { // allow:raw-byte-literal — hour ceiling
|
|
241
|
+
issues.push({
|
|
242
|
+
kind: "hour-range", severity: "high",
|
|
243
|
+
ruleId: "time.hour-range",
|
|
244
|
+
snippet: "hour " + hour + " > 23",
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
if (minute > 59) { // allow:raw-byte-literal — minute ceiling
|
|
248
|
+
issues.push({
|
|
249
|
+
kind: "minute-range", severity: "high",
|
|
250
|
+
ruleId: "time.minute-range",
|
|
251
|
+
snippet: "minute " + minute + " > 59",
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
if (second > 60) { // allow:raw-time-literal — leap-second ceiling, RFC 3339 §5.6 (not seconds-of-time)
|
|
255
|
+
issues.push({
|
|
256
|
+
kind: "second-range", severity: "high",
|
|
257
|
+
ruleId: "time.second-range",
|
|
258
|
+
snippet: "second " + second + " > 60 (RFC 3339 §5.6 ceiling " +
|
|
259
|
+
"including leap)",
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Leap-second flag.
|
|
264
|
+
if (second === 60 && opts.leapSecondPolicy !== "allow") { // allow:raw-time-literal — leap-second sentinel, RFC 3339 §5.6
|
|
265
|
+
issues.push({
|
|
266
|
+
kind: "leap-second",
|
|
267
|
+
severity: opts.leapSecondPolicy === "reject" ? "high" : "warn",
|
|
268
|
+
ruleId: "time.leap-second",
|
|
269
|
+
snippet: "second field is 60 (leap second; RFC 3339 §5.6 valid " +
|
|
270
|
+
"but most parsers panic)",
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Day-in-month structural sanity (light — not full Gregorian
|
|
275
|
+
// rollover; the framework refuses obviously-out-of-bounds dates
|
|
276
|
+
// like Feb 30 / Apr 31).
|
|
277
|
+
var daysInMonth = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; // allow:raw-byte-literal — Gregorian month-day table
|
|
278
|
+
if (month >= 1 && month <= 12 && day > daysInMonth[month - 1]) { // allow:raw-byte-literal — month range
|
|
279
|
+
issues.push({
|
|
280
|
+
kind: "day-in-month", severity: "high",
|
|
281
|
+
ruleId: "time.day-in-month",
|
|
282
|
+
snippet: "day " + day + " not valid in month " + month,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Fractional digits cap.
|
|
287
|
+
var fracLen = fractional.length > 0 ? fractional.length - 1 : 0;
|
|
288
|
+
if (fracLen > opts.maxFractionalDigits &&
|
|
289
|
+
opts.fractionalDigitsPolicy !== "allow") {
|
|
290
|
+
issues.push({
|
|
291
|
+
kind: "fractional-digits",
|
|
292
|
+
severity: opts.fractionalDigitsPolicy === "reject" ? "high" : "warn",
|
|
293
|
+
ruleId: "time.fractional-digits",
|
|
294
|
+
snippet: "fractional precision " + fracLen + " exceeds " +
|
|
295
|
+
opts.maxFractionalDigits + " digits — downstream " +
|
|
296
|
+
"consumers may truncate or reject",
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Naive datetime (no offset).
|
|
301
|
+
if (!offset) {
|
|
302
|
+
if (opts.naiveDatetimePolicy !== "allow") {
|
|
303
|
+
issues.push({
|
|
304
|
+
kind: "naive-datetime",
|
|
305
|
+
severity: opts.naiveDatetimePolicy === "reject" ? "high" : "warn",
|
|
306
|
+
ruleId: "time.naive-datetime",
|
|
307
|
+
snippet: "datetime has no offset (`Z` or `+HH:MM`) — naive " +
|
|
308
|
+
"datetimes break cross-region equality",
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
} else {
|
|
312
|
+
// Non-UTC offset.
|
|
313
|
+
var isUtc = offset === "Z" || offset === "z" ||
|
|
314
|
+
offset === "+00:00" || offset === "-00:00";
|
|
315
|
+
if (!isUtc && opts.nonUtcOffsetPolicy !== "allow") {
|
|
316
|
+
issues.push({
|
|
317
|
+
kind: "non-utc-offset",
|
|
318
|
+
severity: opts.nonUtcOffsetPolicy === "reject" ? "high" : "warn",
|
|
319
|
+
ruleId: "time.non-utc-offset",
|
|
320
|
+
snippet: "datetime offset `" + offset + "` is not UTC — " +
|
|
321
|
+
"strict requires `Z` or `+00:00` for unambiguous " +
|
|
322
|
+
"cross-system comparison",
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return issues;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function validate(input, opts) {
|
|
331
|
+
opts = _resolveOpts(opts);
|
|
332
|
+
numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
|
|
333
|
+
["maxBytes", "minYear", "maxYear", "maxFractionalDigits"],
|
|
334
|
+
"guardTime.validate", GuardTimeError, "time.bad-opt");
|
|
335
|
+
if (typeof input !== "string") {
|
|
336
|
+
return {
|
|
337
|
+
ok: false,
|
|
338
|
+
issues: [{ kind: "bad-input", severity: "high",
|
|
339
|
+
ruleId: "time.bad-input",
|
|
340
|
+
snippet: "time is not a string" }],
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
return gateContract.aggregateIssues(_detectIssues(input, opts));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function sanitize(input, opts) {
|
|
347
|
+
opts = _resolveOpts(opts);
|
|
348
|
+
if (typeof input !== "string") {
|
|
349
|
+
throw _err("time.bad-input", "sanitize requires string input");
|
|
350
|
+
}
|
|
351
|
+
var issues = _detectIssues(input, opts);
|
|
352
|
+
for (var i = 0; i < issues.length; i += 1) {
|
|
353
|
+
if (issues[i].severity === "critical" || issues[i].severity === "high") {
|
|
354
|
+
throw _err(issues[i].ruleId || "time.refused",
|
|
355
|
+
"guardTime.sanitize: " + issues[i].snippet);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
// Normalize: lowercase the trailing `T` separator, uppercase the
|
|
359
|
+
// `Z` UTC marker.
|
|
360
|
+
return input.replace(/(\d) /, "$1T").replace(/z$/, "Z");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function gate(opts) {
|
|
364
|
+
opts = _resolveOpts(opts);
|
|
365
|
+
return gateContract.buildGuardGate(
|
|
366
|
+
opts.name || "guardTime:" + (opts.profile || "default"),
|
|
367
|
+
opts,
|
|
368
|
+
async function (ctx) {
|
|
369
|
+
var identifier = ctx && (ctx.identifier || ctx.timestamp || ctx.time || "");
|
|
370
|
+
if (!identifier) return { ok: true, action: "serve" };
|
|
371
|
+
var rv = validate(identifier, opts);
|
|
372
|
+
if (rv.issues.length === 0) return { ok: true, action: "serve" };
|
|
373
|
+
var hasCritical = rv.issues.some(function (i) {
|
|
374
|
+
return i.severity === "critical";
|
|
375
|
+
});
|
|
376
|
+
var hasHigh = rv.issues.some(function (i) {
|
|
377
|
+
return i.severity === "high";
|
|
378
|
+
});
|
|
379
|
+
if (!hasCritical && !hasHigh) {
|
|
380
|
+
return { ok: true, action: "audit-only", issues: rv.issues };
|
|
381
|
+
}
|
|
382
|
+
return { ok: false, action: "refuse", issues: rv.issues };
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
var buildProfile = gateContract.makeProfileBuilder(PROFILES);
|
|
387
|
+
|
|
388
|
+
function compliancePosture(name) {
|
|
389
|
+
return gateContract.lookupCompliancePosture(name, COMPLIANCE_POSTURES,
|
|
390
|
+
_err, "time");
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
var _timeRulePacks = gateContract.makeRulePackLoader(GuardTimeError, "time");
|
|
394
|
+
var loadRulePack = _timeRulePacks.load;
|
|
395
|
+
|
|
396
|
+
module.exports = {
|
|
397
|
+
// ---- guard-* family registry exports ----
|
|
398
|
+
NAME: "time",
|
|
399
|
+
KIND: "identifier",
|
|
400
|
+
INTEGRATION_FIXTURES: Object.freeze({
|
|
401
|
+
kind: "identifier",
|
|
402
|
+
benignBytes: Buffer.from("2026-05-05T12:34:56Z", "utf8"),
|
|
403
|
+
hostileBytes: Buffer.from("2026-05-05 12:34:56", "utf8"),
|
|
404
|
+
benignIdentifier: "2026-05-05T12:34:56Z",
|
|
405
|
+
// Hostile: naive datetime (space separator + no offset) — refused
|
|
406
|
+
// at strict (cross-region ambiguity class).
|
|
407
|
+
hostileIdentifier: "2026-05-05 12:34:56",
|
|
408
|
+
}),
|
|
409
|
+
// ---- primitive surface ----
|
|
410
|
+
validate: validate,
|
|
411
|
+
sanitize: sanitize,
|
|
412
|
+
gate: gate,
|
|
413
|
+
buildProfile: buildProfile,
|
|
414
|
+
compliancePosture: compliancePosture,
|
|
415
|
+
loadRulePack: loadRulePack,
|
|
416
|
+
PROFILES: PROFILES,
|
|
417
|
+
DEFAULTS: DEFAULTS,
|
|
418
|
+
COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
|
|
419
|
+
GuardTimeError: GuardTimeError,
|
|
420
|
+
};
|
package/package.json
CHANGED
package/sbom.cyclonedx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:49f1481c-f876-4567-878c-2f709b99fd4f",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-05T21:
|
|
8
|
+
"timestamp": "2026-05-05T21:48:20.338Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.7.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.7.47",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.7.
|
|
25
|
+
"version": "0.7.47",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.7.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.7.47",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.7.
|
|
57
|
+
"ref": "@blamejs/core@0.7.47",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|