@sonisoft/now-sdk-ext-core 6.0.0 → 6.2.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/README.md +94 -0
- package/dist/comm/http/HTTPRequest.d.ts +14 -0
- package/dist/comm/http/RequestHandler.d.ts +13 -0
- package/dist/comm/http/RequestHandler.js +58 -5
- package/dist/comm/http/RequestHandler.js.map +1 -1
- package/dist/comm/http/ServiceNowProcessorRequest.d.ts +8 -2
- package/dist/comm/http/ServiceNowProcessorRequest.js +9 -4
- package/dist/comm/http/ServiceNowProcessorRequest.js.map +1 -1
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/policy/Policy.d.ts +40 -0
- package/dist/policy/Policy.js +161 -0
- package/dist/policy/Policy.js.map +1 -0
- package/dist/policy/PolicyEnv.d.ts +46 -0
- package/dist/policy/PolicyEnv.js +92 -0
- package/dist/policy/PolicyEnv.js.map +1 -0
- package/dist/policy/PolicyRefusal.d.ts +17 -0
- package/dist/policy/PolicyRefusal.js +69 -0
- package/dist/policy/PolicyRefusal.js.map +1 -0
- package/dist/policy/PolicyTypes.d.ts +65 -0
- package/dist/policy/PolicyTypes.js +15 -0
- package/dist/policy/PolicyTypes.js.map +1 -0
- package/dist/policy/ScanScript.d.ts +22 -0
- package/dist/policy/ScanScript.js +171 -0
- package/dist/policy/ScanScript.js.map +1 -0
- package/dist/policy/internal/Classify.d.ts +16 -0
- package/dist/policy/internal/Classify.js +121 -0
- package/dist/policy/internal/Classify.js.map +1 -0
- package/dist/sn/Application.js +18 -8
- package/dist/sn/Application.js.map +1 -1
- package/dist/sn/BackgroundScriptExecutor.d.ts +12 -0
- package/dist/sn/BackgroundScriptExecutor.js +60 -1
- package/dist/sn/BackgroundScriptExecutor.js.map +1 -1
- package/dist/sn/atf/ATFTestExecutor.js +3 -1
- package/dist/sn/atf/ATFTestExecutor.js.map +1 -1
- package/dist/sn/syslog/SyslogReader.js +6 -1
- package/dist/sn/syslog/SyslogReader.js.map +1 -1
- package/dist/sn/transaction/ClusterTransactionManager.d.ts +24 -0
- package/dist/sn/transaction/ClusterTransactionManager.js +264 -0
- package/dist/sn/transaction/ClusterTransactionManager.js.map +1 -0
- package/dist/sn/transaction/ClusterTransactionModels.d.ts +35 -0
- package/dist/sn/transaction/ClusterTransactionModels.js +2 -0
- package/dist/sn/transaction/ClusterTransactionModels.js.map +1 -0
- package/package.json +10 -9
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { PolicyLayer, Verb } from "./PolicyTypes.js";
|
|
2
|
+
/**
|
|
3
|
+
* Environment-variable policy layers.
|
|
4
|
+
*
|
|
5
|
+
* `NEX_POLICY_DENY` sits at the top of the ladder and cannot be granted past.
|
|
6
|
+
* `NEX_POLICY_ALLOW` sits near the bottom and exists for backward compatibility, so an
|
|
7
|
+
* existing CI job can restore its previous behaviour with one variable rather than
|
|
8
|
+
* editing every command line.
|
|
9
|
+
*
|
|
10
|
+
* Named `NEX_POLICY_*` rather than `NEX_ALLOW_WRITE` because `write` and `execute`
|
|
11
|
+
* collide with ServiceNow ACL vocabulary, and a reader should not have to work out
|
|
12
|
+
* whether this is about ACLs.
|
|
13
|
+
*/
|
|
14
|
+
export declare const DENY_ENV = "NEX_POLICY_DENY";
|
|
15
|
+
export declare const ALLOW_ENV = "NEX_POLICY_ALLOW";
|
|
16
|
+
export interface ParsedVerbs {
|
|
17
|
+
readonly verbs: readonly Verb[];
|
|
18
|
+
/** Tokens that were not recognised. Non-empty means the value was malformed. */
|
|
19
|
+
readonly unknown: readonly string[];
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Parses a comma-separated verb list.
|
|
23
|
+
*
|
|
24
|
+
* `all` expands to every verb. Unrecognised tokens are REPORTED rather than dropped, so
|
|
25
|
+
* the caller can fail closed — see `denyFromEnvironment`.
|
|
26
|
+
*/
|
|
27
|
+
export declare function parseVerbList(raw: string | undefined): ParsedVerbs;
|
|
28
|
+
/**
|
|
29
|
+
* The deny layer, built from `NEX_POLICY_DENY`.
|
|
30
|
+
*
|
|
31
|
+
* FAILS CLOSED on a malformed value. `NEX_POLICY_DENY=wrtie` denies everything and
|
|
32
|
+
* warns, rather than silently denying nothing — a typo in the variable that is supposed
|
|
33
|
+
* to protect production must not quietly disable the protection. That is the whole
|
|
34
|
+
* reason this function reports `unknown` instead of filtering it away.
|
|
35
|
+
*
|
|
36
|
+
* Returns `undefined` when the variable is unset, so the layer is simply absent.
|
|
37
|
+
*/
|
|
38
|
+
export declare function denyFromEnvironment(env?: NodeJS.ProcessEnv, warn?: (message: string) => void): PolicyLayer | undefined;
|
|
39
|
+
/**
|
|
40
|
+
* The backward-compatibility grant layer, built from `NEX_POLICY_ALLOW`.
|
|
41
|
+
*
|
|
42
|
+
* Unrecognised tokens here are dropped with a warning rather than failing closed: this
|
|
43
|
+
* variable only ever *grants*, so a typo already fails safe — the operation is refused
|
|
44
|
+
* and the operator sees why.
|
|
45
|
+
*/
|
|
46
|
+
export declare function allowFromEnvironment(env?: NodeJS.ProcessEnv, warn?: (message: string) => void): PolicyLayer | undefined;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { denyLayer, grantLayer } from "./Policy.js";
|
|
2
|
+
/**
|
|
3
|
+
* Environment-variable policy layers.
|
|
4
|
+
*
|
|
5
|
+
* `NEX_POLICY_DENY` sits at the top of the ladder and cannot be granted past.
|
|
6
|
+
* `NEX_POLICY_ALLOW` sits near the bottom and exists for backward compatibility, so an
|
|
7
|
+
* existing CI job can restore its previous behaviour with one variable rather than
|
|
8
|
+
* editing every command line.
|
|
9
|
+
*
|
|
10
|
+
* Named `NEX_POLICY_*` rather than `NEX_ALLOW_WRITE` because `write` and `execute`
|
|
11
|
+
* collide with ServiceNow ACL vocabulary, and a reader should not have to work out
|
|
12
|
+
* whether this is about ACLs.
|
|
13
|
+
*/
|
|
14
|
+
export const DENY_ENV = "NEX_POLICY_DENY";
|
|
15
|
+
export const ALLOW_ENV = "NEX_POLICY_ALLOW";
|
|
16
|
+
const ALL = "all";
|
|
17
|
+
const VALID = ["write", "execute"];
|
|
18
|
+
/**
|
|
19
|
+
* Parses a comma-separated verb list.
|
|
20
|
+
*
|
|
21
|
+
* `all` expands to every verb. Unrecognised tokens are REPORTED rather than dropped, so
|
|
22
|
+
* the caller can fail closed — see `denyFromEnvironment`.
|
|
23
|
+
*/
|
|
24
|
+
export function parseVerbList(raw) {
|
|
25
|
+
if (raw === undefined) {
|
|
26
|
+
return { verbs: [], unknown: [] };
|
|
27
|
+
}
|
|
28
|
+
const tokens = raw
|
|
29
|
+
.split(",")
|
|
30
|
+
.map((t) => t.trim().toLowerCase())
|
|
31
|
+
.filter((t) => t.length > 0);
|
|
32
|
+
if (tokens.includes(ALL)) {
|
|
33
|
+
return { verbs: [...VALID], unknown: [] };
|
|
34
|
+
}
|
|
35
|
+
const verbs = [];
|
|
36
|
+
const unknown = [];
|
|
37
|
+
for (const token of tokens) {
|
|
38
|
+
if (VALID.includes(token)) {
|
|
39
|
+
verbs.push(token);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
unknown.push(token);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return { verbs, unknown };
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The deny layer, built from `NEX_POLICY_DENY`.
|
|
49
|
+
*
|
|
50
|
+
* FAILS CLOSED on a malformed value. `NEX_POLICY_DENY=wrtie` denies everything and
|
|
51
|
+
* warns, rather than silently denying nothing — a typo in the variable that is supposed
|
|
52
|
+
* to protect production must not quietly disable the protection. That is the whole
|
|
53
|
+
* reason this function reports `unknown` instead of filtering it away.
|
|
54
|
+
*
|
|
55
|
+
* Returns `undefined` when the variable is unset, so the layer is simply absent.
|
|
56
|
+
*/
|
|
57
|
+
export function denyFromEnvironment(env = process.env, warn = () => undefined) {
|
|
58
|
+
const raw = env[DENY_ENV];
|
|
59
|
+
if (raw === undefined || raw.trim().length === 0) {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
const { verbs, unknown } = parseVerbList(raw);
|
|
63
|
+
if (unknown.length > 0) {
|
|
64
|
+
warn(`${DENY_ENV} contains unrecognised value(s): ${unknown.join(", ")}. ` +
|
|
65
|
+
`Denying all changes. Valid values are: ${VALID.join(", ")}, all.`);
|
|
66
|
+
return denyLayer(`${DENY_ENV} (malformed, failing closed)`, VALID);
|
|
67
|
+
}
|
|
68
|
+
return denyLayer(DENY_ENV, verbs);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The backward-compatibility grant layer, built from `NEX_POLICY_ALLOW`.
|
|
72
|
+
*
|
|
73
|
+
* Unrecognised tokens here are dropped with a warning rather than failing closed: this
|
|
74
|
+
* variable only ever *grants*, so a typo already fails safe — the operation is refused
|
|
75
|
+
* and the operator sees why.
|
|
76
|
+
*/
|
|
77
|
+
export function allowFromEnvironment(env = process.env, warn = () => undefined) {
|
|
78
|
+
const raw = env[ALLOW_ENV];
|
|
79
|
+
if (raw === undefined || raw.trim().length === 0) {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
const { verbs, unknown } = parseVerbList(raw);
|
|
83
|
+
if (unknown.length > 0) {
|
|
84
|
+
warn(`${ALLOW_ENV} contains unrecognised value(s): ${unknown.join(", ")}, ignored. ` +
|
|
85
|
+
`Valid values are: ${VALID.join(", ")}, all.`);
|
|
86
|
+
}
|
|
87
|
+
if (verbs.length === 0) {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
return grantLayer(ALLOW_ENV, verbs);
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=PolicyEnv.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"PolicyEnv.js","sourceRoot":"","sources":["../../src/policy/PolicyEnv.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Decision, Verb } from "./PolicyTypes.js";
|
|
2
|
+
export interface PolicyRefusalError extends Error {
|
|
3
|
+
readonly decision: Decision;
|
|
4
|
+
}
|
|
5
|
+
/** True when `value` is a refusal thrown by ANY copy of this library. */
|
|
6
|
+
export declare function isPolicyRefusal(value: unknown): value is PolicyRefusalError;
|
|
7
|
+
/**
|
|
8
|
+
* Builds the error thrown when an operation is refused.
|
|
9
|
+
*
|
|
10
|
+
* The message states what was needed and what to do about it. It deliberately does NOT
|
|
11
|
+
* name an MCP tool parameter: on that surface the caller is the model, and a refusal
|
|
12
|
+
* that advertises its own escape hatch just teaches the model to set it and retry.
|
|
13
|
+
* The CLI remediation names its flags, because there a human typed the command.
|
|
14
|
+
*/
|
|
15
|
+
export declare function policyRefusal(decision: Decision): PolicyRefusalError;
|
|
16
|
+
/** Convenience for the common single-verb refusal. */
|
|
17
|
+
export declare function refusalFor(verb: Verb, decidingLayer: string, remediation?: string): PolicyRefusalError;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marks an error as a policy refusal.
|
|
3
|
+
*
|
|
4
|
+
* `Symbol.for`, not a class and not `instanceof`. This package is published, and a
|
|
5
|
+
* consumer can easily end up with two copies in `node_modules` — a transitive
|
|
6
|
+
* dependency on an older major, a pruned install, a bundler that duplicated it. With a
|
|
7
|
+
* class, `instanceof` then returns false for an error thrown by the *other* copy, and
|
|
8
|
+
* the failure is silent: `BackgroundScriptExecutor.executeScriptAuto` would stop
|
|
9
|
+
* recognising a refusal and fall back to `sys_trigger`, executing the very script that
|
|
10
|
+
* was just refused.
|
|
11
|
+
*
|
|
12
|
+
* The registry symbol is global to the process, so it identifies correctly across
|
|
13
|
+
* copies. This repo already carries the dual-copy exposure with `StaleInstanceError`
|
|
14
|
+
* (see `RequestHandler.toThrowable`, which goes out of its way to preserve identity);
|
|
15
|
+
* this is that lesson applied rather than repeated.
|
|
16
|
+
*/
|
|
17
|
+
const POLICY_REFUSAL = Symbol.for("now-sdk-ext.policy-refusal");
|
|
18
|
+
/** True when `value` is a refusal thrown by ANY copy of this library. */
|
|
19
|
+
export function isPolicyRefusal(value) {
|
|
20
|
+
if (!value || typeof value !== "object") {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
return value[POLICY_REFUSAL] === true;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Builds the error thrown when an operation is refused.
|
|
27
|
+
*
|
|
28
|
+
* The message states what was needed and what to do about it. It deliberately does NOT
|
|
29
|
+
* name an MCP tool parameter: on that surface the caller is the model, and a refusal
|
|
30
|
+
* that advertises its own escape hatch just teaches the model to set it and retry.
|
|
31
|
+
* The CLI remediation names its flags, because there a human typed the command.
|
|
32
|
+
*/
|
|
33
|
+
export function policyRefusal(decision) {
|
|
34
|
+
const verbs = decision.verbs.join(" and ");
|
|
35
|
+
const detail = decision.remediation
|
|
36
|
+
? `${decision.remediation}`
|
|
37
|
+
: `This operation requires ${verbs} permission, which has not been granted.`;
|
|
38
|
+
const error = new Error(detail);
|
|
39
|
+
error.name = "PolicyRefusalError";
|
|
40
|
+
error[POLICY_REFUSAL] = true;
|
|
41
|
+
// Non-enumerable AND writable, both load-bearing.
|
|
42
|
+
//
|
|
43
|
+
// `stripSecretsFromError` walks `Object.keys(err)` and reassigns every object-valued
|
|
44
|
+
// child through the redactor. An enumerable, non-writable `decision` made that throw
|
|
45
|
+
// `Cannot assign to read only property 'decision'` — which replaced the refusal with
|
|
46
|
+
// a TypeError on the way out, so callers saw a crash instead of a refusal and
|
|
47
|
+
// `isPolicyRefusal` returned false. Found by the live integration run, not by any
|
|
48
|
+
// unit test, because nothing in isolation puts a refusal through the redactor.
|
|
49
|
+
//
|
|
50
|
+
// Non-enumerable also keeps it out of JSON.stringify and out of log serialisation,
|
|
51
|
+
// which is where it would otherwise show up as noise.
|
|
52
|
+
Object.defineProperty(error, "decision", {
|
|
53
|
+
value: decision,
|
|
54
|
+
enumerable: false,
|
|
55
|
+
writable: true,
|
|
56
|
+
configurable: true,
|
|
57
|
+
});
|
|
58
|
+
return error;
|
|
59
|
+
}
|
|
60
|
+
/** Convenience for the common single-verb refusal. */
|
|
61
|
+
export function refusalFor(verb, decidingLayer, remediation) {
|
|
62
|
+
return policyRefusal({
|
|
63
|
+
allowed: false,
|
|
64
|
+
verbs: [verb],
|
|
65
|
+
decidingLayer,
|
|
66
|
+
remediation,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=PolicyRefusal.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"PolicyRefusal.js","sourceRoot":"","sources":["../../src/policy/PolicyRefusal.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;GAeG;AACH,MAAM,cAAc,GAAG,MAAM,CAAC,GAAG,CAAC,4BAA4B,CAAC,CAAC;AAMhE,yEAAyE;AACzE,MAAM,UAAU,eAAe,CAAC,KAAc;IAC1C,IAAI,CAAC,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QACtC,OAAO,KAAK,CAAC;IACjB,CAAC;IACD,OAAQ,KAAiC,CAAC,cAAc,CAAC,KAAK,IAAI,CAAC;AACvE,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,aAAa,CAAC,QAAkB;IAC5C,MAAM,KAAK,GAAG,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IAC3C,MAAM,MAAM,GAAG,QAAQ,CAAC,WAAW;QAC/B,CAAC,CAAC,GAAG,QAAQ,CAAC,WAAW,EAAE;QAC3B,CAAC,CAAC,2BAA2B,KAAK,0CAA0C,CAAC;IAEjF,MAAM,KAAK,GAAG,IAAI,KAAK,CAAC,MAAM,CAAiD,CAAC;IAChF,KAAK,CAAC,IAAI,GAAG,oBAAoB,CAAC;IAClC,KAAK,CAAC,cAAc,CAAC,GAAG,IAAI,CAAC;IAC7B,kDAAkD;IAClD,EAAE;IACF,qFAAqF;IACrF,qFAAqF;IACrF,qFAAqF;IACrF,8EAA8E;IAC9E,kFAAkF;IAClF,+EAA+E;IAC/E,EAAE;IACF,mFAAmF;IACnF,sDAAsD;IACtD,MAAM,CAAC,cAAc,CAAC,KAAK,EAAE,UAAU,EAAE;QACrC,KAAK,EAAE,QAAQ;QACf,UAAU,EAAE,KAAK;QACjB,QAAQ,EAAE,IAAI;QACd,YAAY,EAAE,IAAI;KACrB,CAAC,CAAC;IACH,OAAO,KAAK,CAAC;AACjB,CAAC;AAED,sDAAsD;AACtD,MAAM,UAAU,UAAU,CAAC,IAAU,EAAE,aAAqB,EAAE,WAAoB;IAC9E,OAAO,aAAa,CAAC;QACjB,OAAO,EAAE,KAAK;QACd,KAAK,EAAE,CAAC,IAAI,CAAC;QACb,aAAa;QACb,WAAW;KACd,CAAC,CAAC;AACP,CAAC"}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vocabulary for gating instance mutations.
|
|
3
|
+
*
|
|
4
|
+
* This is a GUARDRAIL, not a security boundary. Anything holding the credential can
|
|
5
|
+
* reach the instance directly — by importing this library and calling the SDK, or with
|
|
6
|
+
* curl. What it buys is that *inadvertent* mutation stops being the default when an AI
|
|
7
|
+
* agent is driving, and that an operator can hard-deny from an environment the agent
|
|
8
|
+
* cannot write to. Do not describe it as anything stronger.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* What an operation does. Two verbs, deliberately.
|
|
12
|
+
*
|
|
13
|
+
* CRUD was considered and rejected: `nex exec`, ATF runs and flow execution all mutate
|
|
14
|
+
* without being create, edit or delete, so a CRUD vocabulary leaves them homeless.
|
|
15
|
+
*/
|
|
16
|
+
export type Verb = "write" | "execute";
|
|
17
|
+
/**
|
|
18
|
+
* WHERE the effect lands. Not every write is a write to the instance.
|
|
19
|
+
*
|
|
20
|
+
* `pull_script` overwrites a local file, `scope set` changes session state, and the
|
|
21
|
+
* `auth` commands write the credential store. Gating those as instance writes would
|
|
22
|
+
* produce false refusals on operations that never touch ServiceNow data, so the target
|
|
23
|
+
* is carried alongside the verb rather than inventing more verbs.
|
|
24
|
+
*
|
|
25
|
+
* Only `instance` is gated today; the rest exist so the distinction is recorded at the
|
|
26
|
+
* call site rather than rediscovered later.
|
|
27
|
+
*/
|
|
28
|
+
export type Target = "instance" | "local" | "session";
|
|
29
|
+
/** What an operation needs before it may proceed. */
|
|
30
|
+
export interface Requirement {
|
|
31
|
+
readonly verbs: readonly Verb[];
|
|
32
|
+
readonly target: Target;
|
|
33
|
+
}
|
|
34
|
+
/** A requirement that needs no permission at all. */
|
|
35
|
+
export declare const READ_ONLY: Requirement;
|
|
36
|
+
/**
|
|
37
|
+
* A layer's answer.
|
|
38
|
+
*
|
|
39
|
+
* `abstain` is distinct from `deny` on purpose — it means "I have no opinion", so a
|
|
40
|
+
* lower-priority layer still gets to speak. Collapsing the two would make an unset
|
|
41
|
+
* environment variable behave like an explicit denial.
|
|
42
|
+
*/
|
|
43
|
+
export type LayerAnswer = "grant" | "deny" | "abstain";
|
|
44
|
+
/** One rung of the precedence ladder. */
|
|
45
|
+
export interface PolicyLayer {
|
|
46
|
+
/** Appears in refusals and the audit log, so make it recognisable to an operator. */
|
|
47
|
+
readonly name: string;
|
|
48
|
+
answer(verb: Verb): LayerAnswer;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The result of a check.
|
|
52
|
+
*
|
|
53
|
+
* An object rather than a boolean so the deciding layer can be reported — "why was this
|
|
54
|
+
* refused" is otherwise unanswerable — and so a future `ApprovalToken` (NEX-76) can be
|
|
55
|
+
* added as a grant layer without changing this signature.
|
|
56
|
+
*/
|
|
57
|
+
export interface Decision {
|
|
58
|
+
readonly allowed: boolean;
|
|
59
|
+
/** The verbs actually required. Empty means the operation needed no permission. */
|
|
60
|
+
readonly verbs: readonly Verb[];
|
|
61
|
+
/** Name of the layer that decided. `"default"` when nothing granted. */
|
|
62
|
+
readonly decidingLayer: string;
|
|
63
|
+
/** Present only when refused. Written for a human, never naming an MCP parameter. */
|
|
64
|
+
readonly remediation?: string;
|
|
65
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vocabulary for gating instance mutations.
|
|
3
|
+
*
|
|
4
|
+
* This is a GUARDRAIL, not a security boundary. Anything holding the credential can
|
|
5
|
+
* reach the instance directly — by importing this library and calling the SDK, or with
|
|
6
|
+
* curl. What it buys is that *inadvertent* mutation stops being the default when an AI
|
|
7
|
+
* agent is driving, and that an operator can hard-deny from an environment the agent
|
|
8
|
+
* cannot write to. Do not describe it as anything stronger.
|
|
9
|
+
*/
|
|
10
|
+
/** A requirement that needs no permission at all. */
|
|
11
|
+
export const READ_ONLY = Object.freeze({
|
|
12
|
+
verbs: Object.freeze([]),
|
|
13
|
+
target: "instance",
|
|
14
|
+
});
|
|
15
|
+
//# sourceMappingURL=PolicyTypes.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"PolicyTypes.js","sourceRoot":"","sources":["../../src/policy/PolicyTypes.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AA6BH,qDAAqD;AACrD,MAAM,CAAC,MAAM,SAAS,GAAgB,MAAM,CAAC,MAAM,CAAC;IAChD,KAAK,EAAE,MAAM,CAAC,MAAM,CAAC,EAAE,CAAoB;IAC3C,MAAM,EAAE,UAAU;CACrB,CAAC,CAAC"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Verb } from "./PolicyTypes.js";
|
|
2
|
+
export interface ScriptScanResult {
|
|
3
|
+
/** Verbs the script needs BEYOND `execute`. Today this is `["write"]` or `[]`. */
|
|
4
|
+
readonly verbs: readonly Verb[];
|
|
5
|
+
/** Why, in the order found. Surfaced to the user so a refusal is explainable. */
|
|
6
|
+
readonly reasons: readonly string[];
|
|
7
|
+
/** True when the scan could not resolve something and escalated as a result. */
|
|
8
|
+
readonly escalated: boolean;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Reports the extra permission a script needs.
|
|
12
|
+
*
|
|
13
|
+
* Call this with the script that will ACTUALLY be sent — after any `{param}`
|
|
14
|
+
* substitution. Scanning the pre-substitution text is a hole you could drive a
|
|
15
|
+
* parameter value of `gr.insert()` through.
|
|
16
|
+
*/
|
|
17
|
+
export declare function scanScript(script: string): ScriptScanResult;
|
|
18
|
+
/** Convenience: what a background script needs in total. */
|
|
19
|
+
export declare function requirementForScript(script: string): {
|
|
20
|
+
verbs: Verb[];
|
|
21
|
+
reasons: readonly string[];
|
|
22
|
+
};
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { parse } from "acorn";
|
|
2
|
+
/**
|
|
3
|
+
* Looks at a server-side script and reports what it appears to do.
|
|
4
|
+
*
|
|
5
|
+
* ## What this is for
|
|
6
|
+
*
|
|
7
|
+
* A background script needs `execute` no matter what. This scan exists to decide
|
|
8
|
+
* whether it ALSO needs `write` — so a genuinely read-only diagnostic script can run
|
|
9
|
+
* under `execute` alone, while one containing `gr.insert()` needs both.
|
|
10
|
+
*
|
|
11
|
+
* ## What it cannot do — read this before trusting it
|
|
12
|
+
*
|
|
13
|
+
* It is unsound, and deliberately so. A server-side script can mutate through routes no
|
|
14
|
+
* static analysis can follow:
|
|
15
|
+
*
|
|
16
|
+
* - a Script Include: `new global.MyUtil().doThing()` — the write is in another file
|
|
17
|
+
* - `gs.eval(someString)`
|
|
18
|
+
* - `sn_ws.RESTMessageV2` calling out to anything
|
|
19
|
+
* - `workflow.startFlow()`, `gs.getUser().setPreference()`
|
|
20
|
+
* - `GlideRecord` reached through a variable this scan never resolves
|
|
21
|
+
*
|
|
22
|
+
* So a `write`-free result means "nothing obviously writes", NOT "this cannot write".
|
|
23
|
+
* That is exactly why `execute` is still required: permitting a script at all is the
|
|
24
|
+
* real decision, and this scan only narrows what else is needed.
|
|
25
|
+
*
|
|
26
|
+
* ## Deny-leaning by construction
|
|
27
|
+
*
|
|
28
|
+
* Every case it cannot resolve escalates to `write` rather than passing. A scanner that
|
|
29
|
+
* defaults permissive on unfamiliar syntax manufactures confidence and is worse than no
|
|
30
|
+
* scanner — it would let `gr['inse' + 'rt']()` through while looking like it checked.
|
|
31
|
+
*/
|
|
32
|
+
/** Methods that mutate records. Matched on name, on any receiver. */
|
|
33
|
+
const MUTATING_METHODS = new Set([
|
|
34
|
+
"insert",
|
|
35
|
+
"update",
|
|
36
|
+
"updateMultiple",
|
|
37
|
+
"deleteRecord",
|
|
38
|
+
"deleteMultiple",
|
|
39
|
+
"insertWithReferences",
|
|
40
|
+
"updateWithReferences",
|
|
41
|
+
"setValue", // stages a change; harmless alone, but never appears without a write
|
|
42
|
+
"setWorkflow", // disabling business rules is only ever done around a write
|
|
43
|
+
]);
|
|
44
|
+
/** Constructs whose behaviour cannot be known statically. */
|
|
45
|
+
const OPAQUE_CALLS = new Set(["eval", "Function", "GlideEvaluator"]);
|
|
46
|
+
const NONE = { verbs: [], reasons: [], escalated: false };
|
|
47
|
+
function writeResult(reasons, escalated) {
|
|
48
|
+
return { verbs: ["write"], reasons, escalated };
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Parses with the loosest grammar that works.
|
|
52
|
+
*
|
|
53
|
+
* Rhino — what ServiceNow runs server-side — is ES5-era, so most scripts parse at 5.
|
|
54
|
+
* Newer syntax appears in scripts written against the newer engine, hence the fallback.
|
|
55
|
+
* If BOTH fail the script is unreadable to us, which escalates rather than passes.
|
|
56
|
+
*/
|
|
57
|
+
function parseScript(script) {
|
|
58
|
+
for (const ecmaVersion of [5, "latest"]) {
|
|
59
|
+
try {
|
|
60
|
+
return { ast: parse(script, { ecmaVersion, silent: true }) };
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// try the next grammar
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { parseError: "could not be parsed" };
|
|
67
|
+
}
|
|
68
|
+
/** Reads a string-valued AST field, or "" when it is anything else. */
|
|
69
|
+
function textOf(value) {
|
|
70
|
+
return typeof value === "string" ? value : "";
|
|
71
|
+
}
|
|
72
|
+
/** Walks every node without depending on acorn-walk's visitor table. */
|
|
73
|
+
function walk(node, visit) {
|
|
74
|
+
if (!node || typeof node !== "object") {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (Array.isArray(node)) {
|
|
78
|
+
for (const child of node) {
|
|
79
|
+
walk(child, visit);
|
|
80
|
+
}
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const record = node;
|
|
84
|
+
if (typeof record.type === "string") {
|
|
85
|
+
visit(record);
|
|
86
|
+
}
|
|
87
|
+
for (const key of Object.keys(record)) {
|
|
88
|
+
if (key === "type" || key === "start" || key === "end" || key === "loc") {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
walk(record[key], visit);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Reports the extra permission a script needs.
|
|
96
|
+
*
|
|
97
|
+
* Call this with the script that will ACTUALLY be sent — after any `{param}`
|
|
98
|
+
* substitution. Scanning the pre-substitution text is a hole you could drive a
|
|
99
|
+
* parameter value of `gr.insert()` through.
|
|
100
|
+
*/
|
|
101
|
+
export function scanScript(script) {
|
|
102
|
+
if (typeof script !== "string" || script.trim().length === 0) {
|
|
103
|
+
return NONE;
|
|
104
|
+
}
|
|
105
|
+
const parsed = parseScript(script);
|
|
106
|
+
if ("parseError" in parsed) {
|
|
107
|
+
// Unreadable, so unknowable. Escalate.
|
|
108
|
+
return writeResult(["the script could not be parsed, so its effects are unknown"], true);
|
|
109
|
+
}
|
|
110
|
+
const reasons = [];
|
|
111
|
+
let escalated = false;
|
|
112
|
+
walk(parsed.ast, (node) => {
|
|
113
|
+
if (node.type !== "CallExpression") {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const callee = node.callee;
|
|
117
|
+
if (!callee) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
// eval(...) / new Function(...) — contents are opaque.
|
|
121
|
+
const calleeName = textOf(callee.name);
|
|
122
|
+
if (callee.type === "Identifier" && OPAQUE_CALLS.has(calleeName)) {
|
|
123
|
+
reasons.push(`calls ${calleeName}(), whose contents cannot be inspected`);
|
|
124
|
+
escalated = true;
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (callee.type !== "MemberExpression") {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
// gr['inse' + 'rt']() — the property is computed, so the method name is not
|
|
131
|
+
// knowable. This is the single most likely way to walk past a naive scan.
|
|
132
|
+
if (callee.computed === true) {
|
|
133
|
+
const property = callee.property;
|
|
134
|
+
const isLiteralName = property?.type === "Literal" && typeof property.value === "string";
|
|
135
|
+
if (!isLiteralName) {
|
|
136
|
+
reasons.push("calls a method chosen at runtime, which cannot be resolved");
|
|
137
|
+
escalated = true;
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const literalName = textOf(property?.value);
|
|
141
|
+
if (MUTATING_METHODS.has(literalName)) {
|
|
142
|
+
reasons.push(`calls .${literalName}()`);
|
|
143
|
+
}
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const property = callee.property;
|
|
147
|
+
if (property?.type === "Identifier") {
|
|
148
|
+
const name = textOf(property.name);
|
|
149
|
+
if (MUTATING_METHODS.has(name)) {
|
|
150
|
+
reasons.push(`calls .${name}()`);
|
|
151
|
+
}
|
|
152
|
+
if (OPAQUE_CALLS.has(name)) {
|
|
153
|
+
// gs.eval(...)
|
|
154
|
+
reasons.push(`calls .${name}(), whose contents cannot be inspected`);
|
|
155
|
+
escalated = true;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
if (reasons.length === 0) {
|
|
160
|
+
return NONE;
|
|
161
|
+
}
|
|
162
|
+
return writeResult([...new Set(reasons)], escalated);
|
|
163
|
+
}
|
|
164
|
+
/** Convenience: what a background script needs in total. */
|
|
165
|
+
export function requirementForScript(script) {
|
|
166
|
+
const scan = scanScript(script);
|
|
167
|
+
// `execute` always. Running caller-supplied code on the instance is the decision
|
|
168
|
+
// being made; the scan only decides whether `write` rides along.
|
|
169
|
+
return { verbs: ["execute", ...scan.verbs], reasons: scan.reasons };
|
|
170
|
+
}
|
|
171
|
+
//# sourceMappingURL=ScanScript.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ScanScript.js","sourceRoot":"","sources":["../../src/policy/ScanScript.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { HTTPRequest } from "../../comm/http/HTTPRequest.js";
|
|
2
|
+
import { Requirement, Verb } from "../PolicyTypes.js";
|
|
3
|
+
/** Matched floor rules, for the audit log and for tests. */
|
|
4
|
+
export declare function floorFor(request: HTTPRequest): {
|
|
5
|
+
verbs: Verb[];
|
|
6
|
+
reasons: string[];
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* The joined requirement for a request.
|
|
10
|
+
*
|
|
11
|
+
* DECLARED replaces the default when present, but the floor is unioned in afterwards so
|
|
12
|
+
* a declaration can correct a wrong default without ever lowering a floor entry.
|
|
13
|
+
*/
|
|
14
|
+
export declare function classify(request: HTTPRequest): Requirement & {
|
|
15
|
+
reasons: string[];
|
|
16
|
+
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
const pathIs = (needle) => (path) => path.split("?")[0].endsWith(needle);
|
|
2
|
+
const pathHas = (needle) => (path) => path.includes(needle);
|
|
3
|
+
/**
|
|
4
|
+
* Un-lowerable minimums.
|
|
5
|
+
*
|
|
6
|
+
* Every entry here exists because the tier-3 default would get it wrong. Adding a new
|
|
7
|
+
* mutating endpoint that travels as a GET means adding a row here — there is no way to
|
|
8
|
+
* detect that statically, so it is called out in the contribution checklist and the
|
|
9
|
+
* audit log is the backstop.
|
|
10
|
+
*/
|
|
11
|
+
const FLOOR = [
|
|
12
|
+
{
|
|
13
|
+
why: "background script execution",
|
|
14
|
+
verbs: ["execute"],
|
|
15
|
+
matches: (p) => pathIs("/sys.scripts.do")(p),
|
|
16
|
+
},
|
|
17
|
+
// NOTE: /xmlhttp.do is deliberately NOT a floor rule, even though the most
|
|
18
|
+
// destructive call in the library (Application.uninstall) goes through it. The same
|
|
19
|
+
// endpoint is used to READ — syslog tailing POSTs to it — and a floor rule cannot be
|
|
20
|
+
// lowered, so listing it here would refuse `nex log` with no way to correct it.
|
|
21
|
+
// Coverage instead comes from three narrower places: the sysparm_delete_all rule
|
|
22
|
+
// below catches the uninstall, the tier-3 default makes every other POST a write,
|
|
23
|
+
// and the two callers that read or execute through it declare so explicitly.
|
|
24
|
+
{
|
|
25
|
+
why: "store application install (issued as a GET)",
|
|
26
|
+
verbs: ["write"],
|
|
27
|
+
matches: (p) => pathHas("/api/sn_appclient/appmanager/app/install")(p),
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
why: "store application update (issued as a GET)",
|
|
31
|
+
verbs: ["write"],
|
|
32
|
+
matches: (p) => pathHas("/api/sn_appclient/appmanager/app/update")(p),
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
why: "sys_trigger scheduled job — persisting a job outlives the request that made it",
|
|
36
|
+
verbs: ["write"],
|
|
37
|
+
matches: (p) => pathHas("/api/now/table/sys_trigger")(p),
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
why: "sysparm_delete_all",
|
|
41
|
+
verbs: ["write"],
|
|
42
|
+
matches: (_p, q) => "sysparm_delete_all" in q,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
why: "sysparm_delete_when_done",
|
|
46
|
+
verbs: ["write"],
|
|
47
|
+
matches: (_p, q) => {
|
|
48
|
+
const value = q["sysparm_delete_when_done"];
|
|
49
|
+
if (value === undefined) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
// Only primitives are meaningful here; an object would stringify to
|
|
53
|
+
// "[object Object]" and match, which would be a false positive.
|
|
54
|
+
const text = typeof value === "string" || typeof value === "number" || typeof value === "boolean"
|
|
55
|
+
? String(value)
|
|
56
|
+
: "";
|
|
57
|
+
return text.toLowerCase() !== "false" && text.length > 0;
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
function normalizeQuery(request) {
|
|
62
|
+
const out = {};
|
|
63
|
+
const query = request.query;
|
|
64
|
+
if (query && typeof query === "object") {
|
|
65
|
+
for (const [key, value] of Object.entries(query)) {
|
|
66
|
+
out[key] = value;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// Some callers put parameters in `fields` (form-encoded) rather than the query
|
|
70
|
+
// string — Application.uninstall sends sysparm_delete_all that way.
|
|
71
|
+
const fields = request.fields;
|
|
72
|
+
if (fields && typeof fields === "object") {
|
|
73
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
74
|
+
if (!(key in out)) {
|
|
75
|
+
out[key] = value;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
/** Matched floor rules, for the audit log and for tests. */
|
|
82
|
+
export function floorFor(request) {
|
|
83
|
+
const path = typeof request.path === "string" ? request.path : "";
|
|
84
|
+
const query = normalizeQuery(request);
|
|
85
|
+
const verbs = new Set();
|
|
86
|
+
const reasons = [];
|
|
87
|
+
for (const rule of FLOOR) {
|
|
88
|
+
if (rule.matches(path, query)) {
|
|
89
|
+
rule.verbs.forEach((v) => verbs.add(v));
|
|
90
|
+
reasons.push(rule.why);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { verbs: [...verbs], reasons };
|
|
94
|
+
}
|
|
95
|
+
/** Tier 3. GET is assumed to read; anything else is assumed to write. */
|
|
96
|
+
function verbDefault(method) {
|
|
97
|
+
const verb = (method ?? "get").toLowerCase();
|
|
98
|
+
return verb === "get" ? [] : ["write"];
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The joined requirement for a request.
|
|
102
|
+
*
|
|
103
|
+
* DECLARED replaces the default when present, but the floor is unioned in afterwards so
|
|
104
|
+
* a declaration can correct a wrong default without ever lowering a floor entry.
|
|
105
|
+
*/
|
|
106
|
+
export function classify(request) {
|
|
107
|
+
const declared = request.requires;
|
|
108
|
+
const base = declared ? declared.verbs : verbDefault(request.method);
|
|
109
|
+
const target = declared?.target ?? "instance";
|
|
110
|
+
const { verbs: floorVerbs, reasons } = floorFor(request);
|
|
111
|
+
const verbs = new Set(base);
|
|
112
|
+
floorVerbs.forEach((v) => verbs.add(v));
|
|
113
|
+
return {
|
|
114
|
+
verbs: [...verbs],
|
|
115
|
+
// A floor match is always about the instance, whatever the caller declared —
|
|
116
|
+
// otherwise declaring `target: "local"` would be a way to opt out of the floor.
|
|
117
|
+
target: floorVerbs.length > 0 ? "instance" : target,
|
|
118
|
+
reasons,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
//# sourceMappingURL=Classify.js.map
|