@clear-capabilities/agentic-security-scanner 0.174.0 → 0.176.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/bin/agentic-security.js +199 -28
- package/dist/2066.index.js +850 -0
- package/dist/2199.index.js +1 -1
- package/dist/2584.index.js +3332 -0
- package/dist/2923.index.js +13 -0
- package/dist/2946.index.js +81 -0
- package/dist/3196.index.js +27 -0
- package/dist/3727.index.js +292 -0
- package/dist/4168.index.js +509 -0
- package/dist/4281.index.js +993 -0
- package/dist/4978bec8fdf5c733dc85.json +283 -0
- package/dist/5099.index.js +96 -0
- package/dist/5616.index.js +1471 -0
- package/dist/5620.index.js +2370 -0
- package/dist/5817.index.js +714 -0
- package/dist/6333.index.js +98 -0
- package/dist/6655.index.js +3221 -0
- package/dist/7838.index.js +446 -974
- package/dist/818.index.js +4 -3
- package/dist/83.index.js +159 -0
- package/dist/8551.index.js +82 -0
- package/dist/8633.index.js +93 -0
- package/dist/8714.index.js +76 -76
- package/dist/8825.index.js +84 -0
- package/dist/932.index.js +186 -0
- package/dist/9560.index.js +358 -19
- package/dist/9800.index.js +6 -1
- package/dist/agentic-security.mjs +10 -10
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/df1e8683e7eda7b2b79c.js +29 -0
- package/package.json +7 -6
- package/src/artifact/CLAUDE.md +4 -0
- package/src/artifact/artifact-evidence.js +63 -0
- package/src/artifact/cosign.js +61 -0
- package/src/artifact/engine-source.js +72 -0
- package/src/artifact/oci-layout.js +47 -0
- package/src/artifact/registry.js +213 -0
- package/src/capabilities/tool-registry.js +2 -0
- package/src/language/haskell-security-rules.js +1 -1
- package/src/language/nixos-hardening.js +37 -0
- package/src/lineage/CLAUDE.md +4 -0
- package/src/lineage/identity-token.js +11 -0
- package/src/lineage/observation-adapters.js +13 -0
- package/src/lineage/runtime-edge-states.js +139 -0
- package/src/lineage/runtime-explorer-view.js +82 -0
- package/src/lineage/runtime-observation.js +11 -0
- package/src/mcp/CLAUDE.md +3 -1
- package/src/mcp/proof-tools.js +132 -0
- package/src/mcp/server.js +1 -1
- package/src/mcp/tools.js +3 -1
- package/src/posture/CLAUDE.md +23 -0
- package/src/posture/artifact-registry.js +4 -0
- package/src/posture/dnr/attack-facts.js +31 -0
- package/src/posture/dnr/cli.js +138 -0
- package/src/posture/dnr/detection-ir.js +123 -0
- package/src/posture/dnr/exposure-window.js +134 -0
- package/src/posture/dnr/hunt.js +84 -0
- package/src/posture/dnr/log-reader.js +189 -0
- package/src/posture/dnr/matchers.js +90 -0
- package/src/posture/dnr/outputs.js +262 -0
- package/src/posture/dnr/payload-classes.js +120 -0
- package/src/posture/dnr/redact.js +25 -0
- package/src/posture/dnr/store.js +57 -0
- package/src/posture/dnr/validate.js +64 -0
- package/src/posture/dnr/view.js +51 -0
- package/src/posture/evaluation/lab/arm-ollama.js +88 -0
- package/src/posture/evaluation/lab/arms.js +142 -0
- package/src/posture/evaluation/lab/cli.js +135 -0
- package/src/posture/evaluation/lab/dataset.js +139 -0
- package/src/posture/evaluation/lab/materialize.js +44 -0
- package/src/posture/evaluation/lab/neutralize.js +144 -0
- package/src/posture/evaluation/lab/report.js +176 -0
- package/src/posture/evaluation/lab/run.js +225 -0
- package/src/posture/evaluation/lab/score.js +135 -0
- package/src/posture/evaluation/lab/scoreboard.js +48 -0
- package/src/posture/evaluation/lab/stats.js +41 -0
- package/src/posture/evaluation/lab/store.js +53 -0
- package/src/posture/fleet/cli.js +248 -63
- package/src/posture/fleet/discover-edges.js +135 -0
- package/src/posture/fleet/enterprise-graph.js +148 -0
- package/src/posture/fleet/fleet-graph.js +9 -4
- package/src/posture/fleet/fleet-manifest.js +50 -5
- package/src/posture/fleet/job-runner.js +153 -0
- package/src/posture/fleet/job-store.js +136 -0
- package/src/posture/fleet/org-health.js +56 -0
- package/src/posture/fleet/scan-cache.js +112 -0
- package/src/posture/fleet/scan-child.js +29 -0
- package/src/posture/proof-fabric/cli.js +34 -6
- package/src/posture/proof-fabric/container.js +57 -0
- package/src/posture/proof-fabric/fix-proof.js +2 -1
- package/src/posture/proof-fabric/generate-python.js +129 -0
- package/src/posture/proof-fabric/generate.js +213 -0
- package/src/posture/proof-fabric/reproducer.js +3 -0
- package/src/posture/proof-fabric/runner.js +20 -11
- package/src/posture/proof-fabric/siblings.js +77 -0
- package/src/posture/routing/policy-router.js +103 -0
- package/src/posture/rule-compiler/cli.js +64 -6
- package/src/posture/rule-compiler/corpus.js +10 -2
- package/src/posture/rule-compiler/mutation.js +72 -1
- package/src/posture/rule-compiler/promotion.js +21 -5
- package/src/posture/rule-compiler/proposal.js +132 -0
- package/src/posture/rule-compiler/store.js +16 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Vulnerability exposure window (PRD 7, requirement 5): combine WHEN the flaw entered the code, WHEN a build containing it
|
|
2
|
+
// first ran, WHEN the fix was committed and deployed, and WHAT telemetry exists, into the span that can actually be hunted.
|
|
3
|
+
//
|
|
4
|
+
// Every date is sourced (provenance, a deployment record, a git commit, a hunt's own telemetry window). A date that has no
|
|
5
|
+
// source is reported as unknown and listed under `unknowns`; the window that depends on it is then `partial`, or
|
|
6
|
+
// `unavailable`, and the output never fills a gap with an assumption. Part of the exposure that predates the telemetry is
|
|
7
|
+
// reported as `unobservableBefore`: it happened, and nothing here can say whether it was exploited.
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import { spawnSync } from 'node:child_process';
|
|
10
|
+
|
|
11
|
+
export const MAX_DEPLOYMENTS = 1000;
|
|
12
|
+
const SHA_RE = /^[0-9a-f]{7,64}$/i;
|
|
13
|
+
const ts = (v) => { if (v == null) return null; const t = Date.parse(v); return Number.isNaN(t) ? null : t; };
|
|
14
|
+
const iso = (t) => (t == null ? null : new Date(t).toISOString());
|
|
15
|
+
|
|
16
|
+
/** Parse a deployment-history file. Returns {deployments, problems}. Unusable entries are listed, never silently dropped. */
|
|
17
|
+
export function loadDeployments(file, { environment = null } = {}) {
|
|
18
|
+
const problems = [];
|
|
19
|
+
let raw;
|
|
20
|
+
try { raw = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) { return { deployments: [], problems: [`could not read deployment history: ${e.code || e.message}`] }; }
|
|
21
|
+
const list = Array.isArray(raw) ? raw : Array.isArray(raw?.deployments) ? raw.deployments : null;
|
|
22
|
+
if (!list) return { deployments: [], problems: ['deployment history must be a list, or an object with a "deployments" list'] };
|
|
23
|
+
if (list.length > MAX_DEPLOYMENTS) problems.push(`only the first ${MAX_DEPLOYMENTS} of ${list.length} deployments were read`);
|
|
24
|
+
const deployments = [];
|
|
25
|
+
list.slice(0, MAX_DEPLOYMENTS).forEach((d, i) => {
|
|
26
|
+
if (!d || typeof d !== 'object') { problems.push(`deployment ${i}: not an object`); return; }
|
|
27
|
+
if (typeof d.commit !== 'string' || !SHA_RE.test(d.commit)) { problems.push(`deployment ${i}: commit must be a hex sha`); return; }
|
|
28
|
+
if (ts(d.deployedAt) == null) { problems.push(`deployment ${i}: deployedAt is not a date`); return; }
|
|
29
|
+
if (environment && d.environment !== environment) return;
|
|
30
|
+
deployments.push({ commit: d.commit.toLowerCase(), deployedAt: iso(ts(d.deployedAt)), environment: typeof d.environment === 'string' ? d.environment.slice(0, 40) : null, artifactDigest: typeof d.artifactDigest === 'string' ? d.artifactDigest.slice(0, 80) : null });
|
|
31
|
+
});
|
|
32
|
+
return { deployments, problems };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function git(repo, args, timeoutMs = 15000) {
|
|
36
|
+
const r = spawnSync('git', args, { cwd: repo, encoding: 'utf8', timeout: timeoutMs, killSignal: 'SIGKILL', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1 << 20 });
|
|
37
|
+
return r.error || r.status === null ? { ok: false, status: null, out: '' } : { ok: true, status: r.status, out: r.stdout.trim() };
|
|
38
|
+
}
|
|
39
|
+
/** true / false / null (null: git could not answer, e.g. an unknown commit or a shallow clone). */
|
|
40
|
+
export function isAncestor(repo, ancestor, descendant) {
|
|
41
|
+
const r = git(repo, ['merge-base', '--is-ancestor', ancestor, descendant]);
|
|
42
|
+
if (!r.ok) return null;
|
|
43
|
+
return r.status === 0 ? true : r.status === 1 ? false : null;
|
|
44
|
+
}
|
|
45
|
+
export function commitDate(repo, sha) {
|
|
46
|
+
const r = git(repo, ['show', '-s', '--format=%cI', sha]);
|
|
47
|
+
return r.ok && r.status === 0 && r.out ? iso(ts(r.out)) : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** True only when the NEWEST recorded deployment is one that contains the flaw: a statement about the record, not about today. */
|
|
51
|
+
function stillExposed(deployments, vulnerable) {
|
|
52
|
+
if (!deployments.length || !vulnerable.length) return false;
|
|
53
|
+
const newest = [...deployments].sort((a, b) => Date.parse(b.deployedAt) - Date.parse(a.deployedAt))[0];
|
|
54
|
+
return vulnerable.some((d) => d.commit === newest.commit && d.deployedAt === newest.deployedAt);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function computeExposureWindow({ finding, repo, deployments: rawDeployments = [], deploymentProblems = [], fixCommit = null, telemetry = null, deps = {} } = {}) {
|
|
58
|
+
// callers may pass records that did not come through loadDeployments; normalise dates so every output is comparable
|
|
59
|
+
const deployments = rawDeployments.filter((d) => d && ts(d.deployedAt) != null).map((d) => ({ ...d, deployedAt: iso(ts(d.deployedAt)) }));
|
|
60
|
+
const anc = deps.isAncestor ?? ((a, d) => isAncestor(repo, a, d));
|
|
61
|
+
const cdate = deps.commitDate ?? ((sha) => commitDate(repo, sha));
|
|
62
|
+
const unknowns = [];
|
|
63
|
+
const origin = finding?.findingProvenance?.findingOrigin ?? null;
|
|
64
|
+
const introCommit = origin?.commit ?? null;
|
|
65
|
+
const introducedAt = iso(ts(origin?.authorDate ?? origin?.committerDate));
|
|
66
|
+
if (!introCommit) unknowns.push('introduction commit (the finding has no resolved origin)');
|
|
67
|
+
else if (!introducedAt) unknowns.push('introduction date');
|
|
68
|
+
|
|
69
|
+
const fixAt = fixCommit ? cdate(fixCommit) : null;
|
|
70
|
+
if (!fixCommit) unknowns.push('fix commit (not supplied)');
|
|
71
|
+
else if (!fixAt) unknowns.push('fix commit date (the commit could not be read)');
|
|
72
|
+
|
|
73
|
+
const unresolved = [];
|
|
74
|
+
const vulnerable = []; const fixed = [];
|
|
75
|
+
if (!deployments.length) unknowns.push('deployment history (none supplied)');
|
|
76
|
+
for (const d of deployments) {
|
|
77
|
+
if (!introCommit) break;
|
|
78
|
+
const hasBug = anc(introCommit, d.commit);
|
|
79
|
+
if (hasBug === null) { unresolved.push({ commit: d.commit, deployedAt: d.deployedAt, reason: 'ancestry could not be determined' }); continue; }
|
|
80
|
+
const hasFix = fixCommit ? anc(fixCommit, d.commit) : false;
|
|
81
|
+
if (fixCommit && hasFix === null) { unresolved.push({ commit: d.commit, deployedAt: d.deployedAt, reason: 'fix ancestry could not be determined' }); continue; }
|
|
82
|
+
if (hasBug && !hasFix) vulnerable.push(d);
|
|
83
|
+
if (hasFix) fixed.push(d);
|
|
84
|
+
}
|
|
85
|
+
if (unresolved.length) unknowns.push(`${unresolved.length} deployment(s) whose contents could not be resolved against the introduction/fix commits`);
|
|
86
|
+
const byTime = (a, b) => Date.parse(a.deployedAt) - Date.parse(b.deployedAt);
|
|
87
|
+
const firstVulnerable = [...vulnerable].sort(byTime)[0] ?? null;
|
|
88
|
+
const fixDeployedAt = [...fixed].sort(byTime)[0] ?? null;
|
|
89
|
+
if (introCommit && deployments.length && !firstVulnerable && !unresolved.length) unknowns.push('no recorded deployment contains the vulnerable code');
|
|
90
|
+
if (fixCommit && deployments.length && !fixDeployedAt && !unresolved.length) unknowns.push('the fix has not been deployed according to the deployment history');
|
|
91
|
+
|
|
92
|
+
const exposureStart = ts(firstVulnerable?.deployedAt);
|
|
93
|
+
const exposureEnd = ts(fixDeployedAt?.deployedAt);
|
|
94
|
+
const telStart = ts(telemetry?.start); const telEnd = ts(telemetry?.end);
|
|
95
|
+
if (telStart == null || telEnd == null) unknowns.push('telemetry window (no dated telemetry)');
|
|
96
|
+
|
|
97
|
+
let observable = null; let unobservableBefore = null;
|
|
98
|
+
if (exposureStart != null) {
|
|
99
|
+
const end = exposureEnd ?? telEnd;
|
|
100
|
+
if (telStart != null && telEnd != null && end != null) {
|
|
101
|
+
const s = Math.max(exposureStart, telStart); const e = Math.min(end, telEnd);
|
|
102
|
+
if (s <= e) observable = { start: iso(s), end: iso(e), openEnded: exposureEnd == null };
|
|
103
|
+
if (exposureStart < telStart) unobservableBefore = { start: iso(exposureStart), end: iso(Math.min(telStart, end ?? telStart)) };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const status = exposureStart == null ? 'unavailable' : (unknowns.length || !observable) ? 'partial' : 'complete';
|
|
107
|
+
return {
|
|
108
|
+
schema: 'agentic-security/exposure-window', version: 1, findingId: finding?.stableId ?? finding?.id ?? null, status,
|
|
109
|
+
vulnerabilityIntroduced: introducedAt ? { at: introducedAt, commit: introCommit, source: 'finding-provenance' } : null,
|
|
110
|
+
firstVulnerableDeployment: firstVulnerable ? { at: firstVulnerable.deployedAt, commit: firstVulnerable.commit, environment: firstVulnerable.environment, artifactDigest: firstVulnerable.artifactDigest } : null,
|
|
111
|
+
fixCommitted: fixAt ? { at: fixAt, commit: fixCommit } : null,
|
|
112
|
+
fixDeployed: fixDeployedAt ? { at: fixDeployedAt.deployedAt, commit: fixDeployedAt.commit, environment: fixDeployedAt.environment, artifactDigest: fixDeployedAt.artifactDigest } : null,
|
|
113
|
+
stillExposed: stillExposed(deployments, vulnerable),
|
|
114
|
+
telemetryWindow: telStart != null && telEnd != null ? { start: iso(telStart), end: iso(telEnd) } : null,
|
|
115
|
+
observableWindow: observable, unobservableBefore, unresolvedDeployments: unresolved, deploymentProblems, unknowns,
|
|
116
|
+
statement: 'The observable window is the part of the exposure that telemetry covers. Exposure outside it is unobserved, which is not the same as unexploited.',
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function formatExposureWindow(w) {
|
|
121
|
+
const day = (x) => (x ? x.at.slice(0, 10) : 'unknown');
|
|
122
|
+
const lines = [
|
|
123
|
+
`Vulnerability introduced: ${day(w.vulnerabilityIntroduced)}`,
|
|
124
|
+
`First vulnerable deployment: ${day(w.firstVulnerableDeployment)}`,
|
|
125
|
+
`Fix committed: ${day(w.fixCommitted)}`,
|
|
126
|
+
`Fix deployed: ${w.fixDeployed ? day(w.fixDeployed) : w.stillExposed ? 'not deployed (still exposed)' : 'unknown'}`,
|
|
127
|
+
`Telemetry available: ${w.telemetryWindow ? `${w.telemetryWindow.start.slice(0, 10)} to ${w.telemetryWindow.end.slice(0, 10)}` : 'unknown'}`,
|
|
128
|
+
'',
|
|
129
|
+
`Observable exposure window: ${w.observableWindow ? `${w.observableWindow.start.slice(0, 10)} to ${w.observableWindow.openEnded ? 'present (fix not deployed)' : w.observableWindow.end.slice(0, 10)}` : 'cannot be determined'}`,
|
|
130
|
+
];
|
|
131
|
+
if (w.unobservableBefore) lines.push(`Exposed but not observable: ${w.unobservableBefore.start.slice(0, 10)} to ${w.unobservableBefore.end.slice(0, 10)} (before telemetry began)`);
|
|
132
|
+
if (w.unknowns.length) lines.push('', `Status: ${w.status}. Unknown: ${w.unknowns.join('; ')}`);
|
|
133
|
+
return lines.join('\n');
|
|
134
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Historical hunting (PRD 7, requirement 4): search local logs for requests that have the shape of the proven exploit.
|
|
2
|
+
//
|
|
3
|
+
// Four outcomes, and the rules for reaching each are deliberately asymmetric:
|
|
4
|
+
// OBSERVED at least one event matches. A match is a lead, not a verdict of compromise.
|
|
5
|
+
// NOT_OBSERVED nothing matched AND the evidence was good enough to look: logs readable and parsed cleanly, every
|
|
6
|
+
// field the detection needs present on the events, the read not cut short, timestamps available.
|
|
7
|
+
// INCONCLUSIVE nothing matched but one of those conditions failed. This is the honest answer, never NOT_OBSERVED.
|
|
8
|
+
// TELEMETRY_UNAVAILABLE no readable request records at all.
|
|
9
|
+
//
|
|
10
|
+
// NOT_OBSERVED never means NOT_EXPLOITED, and the result says so in a field a consumer can test (`notExploitedClaim: false`)
|
|
11
|
+
// and in a sentence a person reads.
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { readLogs } from './log-reader.js';
|
|
14
|
+
import { evaluateEvent, requiredFields } from './matchers.js';
|
|
15
|
+
import { redactValue, fingerprint } from './redact.js';
|
|
16
|
+
|
|
17
|
+
export const HUNT_VERDICTS = Object.freeze(['OBSERVED', 'NOT_OBSERVED', 'INCONCLUSIVE', 'TELEMETRY_UNAVAILABLE']);
|
|
18
|
+
const MAX_SAMPLES = 20;
|
|
19
|
+
const MAX_PARSE_FAILURE_RATIO = 0.05;
|
|
20
|
+
|
|
21
|
+
const STATEMENTS = {
|
|
22
|
+
OBSERVED: (n) => `${n} request(s) have the shape of this exploit. A match is a lead to investigate, not proof of compromise: a scanner or a test could produce the same request.`,
|
|
23
|
+
NOT_OBSERVED: () => 'No matching request was found in the searched logs. This does not establish that the vulnerability was not exploited: exploitation could have used a route, encoding or time these logs do not cover.',
|
|
24
|
+
INCONCLUSIVE: () => 'No matching request was found, but the logs could not support a negative answer. See the caveats; do not read this as "not exploited".',
|
|
25
|
+
TELEMETRY_UNAVAILABLE: () => 'There were no readable request records to search, so nothing can be said about past exploitation.',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const FIELD_COUNT_KEY = { method: 'method', path: 'path', query: 'query', body: 'body', headers: 'headers', status: 'status', auth: 'auth' };
|
|
29
|
+
|
|
30
|
+
export async function huntLogs({ ir, logsPath, since = null, until = null, signal, limits } = {}) {
|
|
31
|
+
const base = { schema: 'agentic-security/hunt-result', version: 1, findingId: ir.findingId, detectionId: ir.id, detectionDigest: ir.digest, notExploitedClaim: false, egress: 'none' };
|
|
32
|
+
const sinceMs = since ? Date.parse(since) : null;
|
|
33
|
+
const untilMs = until ? Date.parse(until) : null;
|
|
34
|
+
if ((since && Number.isNaN(sinceMs)) || (until && Number.isNaN(untilMs))) {
|
|
35
|
+
return { ...base, verdict: 'INCONCLUSIVE', caveats: ['the --since/--until value is not a valid date, so nothing was searched'], matches: { count: 0, samples: [] }, statement: STATEMENTS.INCONCLUSIVE() };
|
|
36
|
+
}
|
|
37
|
+
const sampleList = [];
|
|
38
|
+
let matchCount = 0; let firstSeen = null; let lastSeen = null;
|
|
39
|
+
let undecidable = 0; const undecidableFields = new Set();
|
|
40
|
+
let endpointEvents = 0; let outOfWindow = 0; let undated = 0;
|
|
41
|
+
|
|
42
|
+
const stats = await readLogs(logsPath, {
|
|
43
|
+
signal, limits,
|
|
44
|
+
onEvent(ev) {
|
|
45
|
+
if (sinceMs != null || untilMs != null) {
|
|
46
|
+
if (!ev.time) { undated++; return; }
|
|
47
|
+
const t = Date.parse(ev.time);
|
|
48
|
+
if ((sinceMs != null && t < sinceMs) || (untilMs != null && t > untilMs)) { outOfWindow++; return; }
|
|
49
|
+
}
|
|
50
|
+
const r = evaluateEvent(ir, ev);
|
|
51
|
+
if (r.undecidable) { undecidable++; r.missing.forEach((m) => undecidableFields.add(m)); return; }
|
|
52
|
+
if (ir.endpoint && ev.method === ir.endpoint.method) endpointEvents++;
|
|
53
|
+
if (!r.match) return;
|
|
54
|
+
matchCount++;
|
|
55
|
+
if (ev.time) { if (!firstSeen || ev.time < firstSeen) firstSeen = ev.time; if (!lastSeen || ev.time > lastSeen) lastSeen = ev.time; }
|
|
56
|
+
if (sampleList.length < MAX_SAMPLES) sampleList.push({ time: ev.time, method: ev.method, path: redactValue(ev.path, 160), matchedOn: r.matchedOn, valueFingerprint: fingerprint(r.value), valuePreview: redactValue(r.value, 80), status: ev.status });
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const logs = { source: path.basename(String(logsPath)), files: stats.files.length, filesSkipped: stats.filesSkipped, lines: stats.lines, parsed: stats.parsed, failed: stats.failed, truncated: stats.truncated };
|
|
61
|
+
const window = { start: stats.windowStart, end: stats.windowEnd, datedEvents: stats.eventsWithTime };
|
|
62
|
+
const caveats = [];
|
|
63
|
+
const result = (verdict) => ({ ...base, verdict, logs, window, coverage: { requestedWindow: { since, until }, outOfWindow, undated, endpointEvents, requiredFields: requiredFields(ir), fieldAvailability: Object.fromEntries(Object.entries(FIELD_COUNT_KEY).map(([k, key]) => [k, stats.parsed ? Number((stats.fieldCounts[key] / stats.parsed).toFixed(3)) : 0])) }, matches: { count: matchCount, firstSeen, lastSeen, samples: sampleList }, caveats, statement: STATEMENTS[verdict](matchCount) });
|
|
64
|
+
|
|
65
|
+
if (stats.unreadable || stats.parsed === 0) {
|
|
66
|
+
caveats.push(stats.unreadable ? 'the log path could not be read' : stats.lines === 0 ? 'the logs contain no lines' : 'no line was recognised as a request record (supported: common/combined access logs, JSON lines)');
|
|
67
|
+
return result('TELEMETRY_UNAVAILABLE');
|
|
68
|
+
}
|
|
69
|
+
if (matchCount > 0) {
|
|
70
|
+
if (stats.truncated.length || stats.aborted) caveats.push(`the read was cut short (${[...stats.truncated, ...(stats.aborted ? ['aborted'] : [])].join(', ')}); more matches may exist`);
|
|
71
|
+
return result('OBSERVED');
|
|
72
|
+
}
|
|
73
|
+
// nothing matched: decide whether a negative answer is supportable
|
|
74
|
+
const failRatio = stats.lines ? stats.failed / stats.lines : 0;
|
|
75
|
+
if (failRatio > MAX_PARSE_FAILURE_RATIO) caveats.push(`${(failRatio * 100).toFixed(1)}% of lines could not be parsed (limit ${MAX_PARSE_FAILURE_RATIO * 100}%)`);
|
|
76
|
+
if (stats.truncated.length || stats.aborted || stats.budgetExhausted) caveats.push(`the read was cut short (${[...stats.truncated, ...(stats.aborted ? ['aborted'] : [])].join(', ') || 'budget'})`);
|
|
77
|
+
if (undecidable > 0) caveats.push(`${undecidable} event(s) lacked a field this detection needs (${[...undecidableFields].join(', ')}), so they could not be judged`);
|
|
78
|
+
if (!stats.eventsWithTime) caveats.push('no event carries a timestamp, so the searched time window is unknown');
|
|
79
|
+
if (stats.filesSkipped.some((f) => f.reason !== 'not-a-log-extension')) caveats.push(`${stats.filesSkipped.filter((f) => f.reason !== 'not-a-log-extension').length} file(s) were skipped (${[...new Set(stats.filesSkipped.map((f) => f.reason))].join(', ')})`);
|
|
80
|
+
if (undated > 0) caveats.push(`${undated} event(s) had no timestamp and were excluded by the requested window`);
|
|
81
|
+
if (!caveats.length && ir.endpoint && endpointEvents === 0) caveats.push('no request to the endpoint appears in the logs at all (informational: nothing to exploit was seen either)');
|
|
82
|
+
const blocking = caveats.some((c) => !c.startsWith('no request to the endpoint'));
|
|
83
|
+
return result(blocking ? 'INCONCLUSIVE' : 'NOT_OBSERVED');
|
|
84
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Bounded, streaming reader for historical HTTP logs (PRD 7, requirement 4). Logs are hostile input: a file may be huge,
|
|
2
|
+
// binary, symlinked, line-less or crafted to blow up a parser. Everything here has a cap and a clock, and every cap that is
|
|
3
|
+
// reached is REPORTED (`truncated`, `budgetExhausted`, skipped-file reasons) so the hunt can answer INCONCLUSIVE rather than
|
|
4
|
+
// pretend it searched everything.
|
|
5
|
+
//
|
|
6
|
+
// Production logs stay local: this module reads files and calls a callback. It imports no network module, and a test pins
|
|
7
|
+
// that. Understood shapes: Apache/nginx common and combined access logs, JSON lines (including Envoy and generic structured
|
|
8
|
+
// loggers). Anything else on a line is a parse failure, counted, never guessed at.
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import readline from 'node:readline';
|
|
12
|
+
|
|
13
|
+
export const LIMITS = Object.freeze({ maxFiles: 500, maxDepth: 4, maxFileBytes: 256 * 1024 * 1024, maxTotalBytes: 2 * 1024 * 1024 * 1024, maxLines: 20_000_000, maxLineChars: 32 * 1024, maxParams: 200, budgetMs: 10 * 60 * 1000 });
|
|
14
|
+
const TEXT_EXT = /\.(?:log|txt|json|jsonl|ndjson|access|out)$|^[^.]+$/i;
|
|
15
|
+
const MONTHS = { Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5, Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11 };
|
|
16
|
+
const CLF = /^(\S+) \S+ (\S+) \[([^\]]+)\] "([A-Z]{3,8}) (\S+)(?: HTTP\/[\d.]+)?" (\d{3}|-) (?:\d+|-)/;
|
|
17
|
+
|
|
18
|
+
const decode = (s) => { try { return decodeURIComponent(String(s).replace(/\+/g, ' ')); } catch { return String(s); } };
|
|
19
|
+
|
|
20
|
+
export function parseQuery(qs, cap = LIMITS.maxParams) {
|
|
21
|
+
const out = {};
|
|
22
|
+
let n = 0;
|
|
23
|
+
for (const part of String(qs ?? '').split('&')) {
|
|
24
|
+
if (!part) continue;
|
|
25
|
+
if (++n > cap) break;
|
|
26
|
+
const i = part.indexOf('=');
|
|
27
|
+
const k = decode(i === -1 ? part : part.slice(0, i));
|
|
28
|
+
const v = i === -1 ? '' : decode(part.slice(i + 1));
|
|
29
|
+
if (k.length > 128) continue;
|
|
30
|
+
(out[k] ??= []).push(v);
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseTime(v) {
|
|
36
|
+
if (v == null || v === '') return null;
|
|
37
|
+
if (typeof v === 'number' && Number.isFinite(v)) { const ms = v > 1e12 ? v : v * 1000; const d = new Date(ms); return Number.isNaN(d.getTime()) ? null : d.toISOString(); }
|
|
38
|
+
const s = String(v);
|
|
39
|
+
const clf = /^(\d{1,2})\/([A-Za-z]{3})\/(\d{4}):(\d{2}):(\d{2}):(\d{2}) ([+-]\d{4})$/.exec(s);
|
|
40
|
+
if (clf && clf[2] in MONTHS) {
|
|
41
|
+
const off = clf[7];
|
|
42
|
+
const sign = off[0] === '-' ? -1 : 1;
|
|
43
|
+
const offMin = sign * (Number(off.slice(1, 3)) * 60 + Number(off.slice(3, 5)));
|
|
44
|
+
return new Date(Date.UTC(Number(clf[3]), MONTHS[clf[2]], Number(clf[1]), Number(clf[4]), Number(clf[5]), Number(clf[6])) - offMin * 60000).toISOString();
|
|
45
|
+
}
|
|
46
|
+
if (/^\d{9,13}(?:\.\d+)?$/.test(s)) return parseTime(Number(s));
|
|
47
|
+
const d = new Date(s);
|
|
48
|
+
return Number.isNaN(d.getTime()) ? null : d.toISOString();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function flatten(obj, prefix = '', depth = 0, out = {}) {
|
|
52
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
53
|
+
const key = prefix ? `${prefix}.${k}` : k;
|
|
54
|
+
if (v && typeof v === 'object' && !Array.isArray(v) && depth < 3) flatten(v, key, depth + 1, out);
|
|
55
|
+
else out[key.toLowerCase()] = v;
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
const pick = (flat, names) => { for (const n of names) if (flat[n] !== undefined && flat[n] !== null && flat[n] !== '') return flat[n]; return undefined; };
|
|
60
|
+
|
|
61
|
+
function fromRequestTarget(target) {
|
|
62
|
+
const t = String(target);
|
|
63
|
+
const i = t.indexOf('?');
|
|
64
|
+
const p = i === -1 ? t : t.slice(0, i);
|
|
65
|
+
return { path: p.startsWith('http') ? (() => { try { return new URL(p).pathname; } catch { return null; } })() : p, query: i === -1 ? {} : parseQuery(t.slice(i + 1)) };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** One log line -> normalized event, or null when it is not a request record in a shape this reader understands. */
|
|
69
|
+
export function parseLine(line) {
|
|
70
|
+
const text = line.trim();
|
|
71
|
+
if (!text) return undefined; // blank: neither parsed nor failed
|
|
72
|
+
if (text[0] === '{') {
|
|
73
|
+
let obj;
|
|
74
|
+
try { obj = JSON.parse(text); } catch { return null; }
|
|
75
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return null;
|
|
76
|
+
const f = flatten(obj);
|
|
77
|
+
const method = pick(f, ['method', 'request_method', 'http.request.method', 'http.method', 'req.method', 'request.method', 'httpmethod', 'verb']);
|
|
78
|
+
let target = pick(f, ['path', 'uri', 'url', 'request_uri', ':path', 'url.path', 'request.path', 'req.url', 'http.url', 'request.uri', 'url.original', 'requesturi']);
|
|
79
|
+
const requestLine = pick(f, ['request']); // "GET /x HTTP/1.1" in some structured loggers
|
|
80
|
+
let m = method;
|
|
81
|
+
if (!target && typeof requestLine === 'string') { const rl = /^([A-Z]{3,8}) (\S+)/.exec(requestLine); if (rl) { m = m ?? rl[1]; target = rl[2]; } }
|
|
82
|
+
if (typeof target !== 'string' && typeof m !== 'string') return null;
|
|
83
|
+
// `flatten` dissolves nested objects, so a structured body or query is read from the original object
|
|
84
|
+
const nested = (...chain) => chain.reduce((acc, k) => (acc && typeof acc === 'object' ? acc[k] : undefined), obj);
|
|
85
|
+
const qsField = pick(f, ['query', 'query_string', 'url.query', 'request.query', 'querystring']) ?? nested('query') ?? nested('request', 'query') ?? nested('url', 'query');
|
|
86
|
+
const tgt = typeof target === 'string' ? fromRequestTarget(target) : { path: undefined, query: undefined };
|
|
87
|
+
let query = tgt.query;
|
|
88
|
+
if (typeof qsField === 'string') query = { ...(query ?? {}), ...parseQuery(qsField.replace(/^\?/, '')) };
|
|
89
|
+
else if (qsField && typeof qsField === 'object') query = { ...(query ?? {}), ...Object.fromEntries(Object.entries(qsField).slice(0, LIMITS.maxParams).map(([k, v]) => [k, [].concat(v).map(String)])) };
|
|
90
|
+
const bodyRaw = pick(f, ['body', 'request_body', 'request.body', 'http.request.body.content', 'req.body', 'payload']) ?? nested('body') ?? nested('request', 'body') ?? nested('req', 'body') ?? nested('http', 'request', 'body', 'content') ?? nested('payload');
|
|
91
|
+
let bodyParams = null;
|
|
92
|
+
if (typeof bodyRaw === 'string') {
|
|
93
|
+
try { const j = JSON.parse(bodyRaw); bodyParams = j && typeof j === 'object' && !Array.isArray(j) ? Object.fromEntries(Object.entries(flatten(j)).slice(0, LIMITS.maxParams).map(([k, v]) => [k, [String(v)]])) : { '': [bodyRaw] }; } catch { bodyParams = bodyRaw.includes('=') ? parseQuery(bodyRaw) : { '': [bodyRaw] }; }
|
|
94
|
+
} else if (bodyRaw && typeof bodyRaw === 'object') bodyParams = Object.fromEntries(Object.entries(flatten(bodyRaw)).slice(0, LIMITS.maxParams).map(([k, v]) => [k, [String(v)]]));
|
|
95
|
+
const headers = {};
|
|
96
|
+
let sawHeaders = false;
|
|
97
|
+
for (const [k, v] of Object.entries(f)) {
|
|
98
|
+
const hm = /^(?:request\.)?headers?\.(.+)$/.exec(k) || /^http\.request\.headers\.(.+)$/.exec(k);
|
|
99
|
+
if (hm) { headers[hm[1]] = String(v); sawHeaders = true; }
|
|
100
|
+
}
|
|
101
|
+
const authHeader = headers.authorization ?? pick(f, ['authorization']);
|
|
102
|
+
const authFlag = pick(f, ['authenticated', 'is_authenticated', 'auth.present']);
|
|
103
|
+
const authPresent = authHeader != null ? true : typeof authFlag === 'boolean' ? authFlag : (sawHeaders ? false : null);
|
|
104
|
+
const statusRaw = pick(f, ['status', 'status_code', 'response_code', 'http.response.status_code', 'sc-status', 'response.status', 'res.statuscode']);
|
|
105
|
+
const status = Number.isInteger(Number(statusRaw)) && statusRaw !== undefined ? Number(statusRaw) : null;
|
|
106
|
+
return { time: parseTime(pick(f, ['@timestamp', 'timestamp', 'time', 'ts', 'start_time', 'datetime', 'date'])), method: typeof m === 'string' ? m.toUpperCase() : null, path: tgt.path ?? null, query: query ?? null, bodyParams, headers: sawHeaders ? headers : null, status, authPresent };
|
|
107
|
+
}
|
|
108
|
+
const c = CLF.exec(text);
|
|
109
|
+
if (c) {
|
|
110
|
+
const { path: p, query } = fromRequestTarget(c[5]);
|
|
111
|
+
return { time: parseTime(c[3]), method: c[4], path: p, query, bodyParams: null, headers: null, status: c[6] === '-' ? null : Number(c[6]), authPresent: null };
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function collectFiles(root, limits, skipped) {
|
|
117
|
+
const out = [];
|
|
118
|
+
const walk = (p, depth) => {
|
|
119
|
+
let st;
|
|
120
|
+
try { st = fs.lstatSync(p); } catch { skipped.push({ file: path.basename(p), reason: 'unreadable' }); return; }
|
|
121
|
+
if (st.isSymbolicLink()) { skipped.push({ file: path.basename(p), reason: 'symlink' }); return; }
|
|
122
|
+
if (st.isDirectory()) {
|
|
123
|
+
if (depth > limits.maxDepth) { skipped.push({ file: path.basename(p), reason: 'too-deep' }); return; }
|
|
124
|
+
let names = [];
|
|
125
|
+
try { names = fs.readdirSync(p).sort(); } catch { skipped.push({ file: path.basename(p), reason: 'unreadable' }); return; }
|
|
126
|
+
for (const n of names) walk(path.join(p, n), depth + 1);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (!st.isFile()) return;
|
|
130
|
+
if (/\.(?:gz|zip|bz2|xz|zst|tgz)$/i.test(p)) { skipped.push({ file: path.basename(p), reason: 'compressed-unsupported' }); return; }
|
|
131
|
+
if (!TEXT_EXT.test(path.basename(p))) { skipped.push({ file: path.basename(p), reason: 'not-a-log-extension' }); return; }
|
|
132
|
+
if (out.length >= limits.maxFiles) { skipped.push({ file: path.basename(p), reason: 'file-count-limit' }); return; }
|
|
133
|
+
out.push({ file: p, size: st.size });
|
|
134
|
+
};
|
|
135
|
+
walk(root, 0);
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Stream every request record under `target` through `onEvent(event)`. Never throws on bad content.
|
|
141
|
+
* @returns stats: files, lines, parsed/failed counts, time window, per-field availability, and every limit that was hit.
|
|
142
|
+
*/
|
|
143
|
+
export async function readLogs(target, { onEvent, signal, limits: over = {} } = {}) {
|
|
144
|
+
const limits = { ...LIMITS, ...over };
|
|
145
|
+
const stats = { files: [], filesSkipped: [], lines: 0, parsed: 0, failed: 0, oversizeLines: 0, windowStart: null, windowEnd: null, eventsWithTime: 0, fieldCounts: { method: 0, path: 0, query: 0, body: 0, headers: 0, status: 0, auth: 0 }, truncated: [], budgetExhausted: false, aborted: false, unreadable: false };
|
|
146
|
+
let st;
|
|
147
|
+
try { st = fs.lstatSync(target); } catch { stats.unreadable = true; return stats; }
|
|
148
|
+
if (st.isSymbolicLink()) { stats.unreadable = true; stats.filesSkipped.push({ file: path.basename(target), reason: 'symlink' }); return stats; }
|
|
149
|
+
const base = st.isDirectory() ? target : path.dirname(target);
|
|
150
|
+
const files = st.isDirectory() ? collectFiles(target, limits, stats.filesSkipped) : collectFiles(target, limits, stats.filesSkipped);
|
|
151
|
+
const deadline = Date.now() + limits.budgetMs;
|
|
152
|
+
let totalBytes = 0;
|
|
153
|
+
for (const { file, size } of files) {
|
|
154
|
+
if (signal?.aborted) { stats.aborted = true; break; }
|
|
155
|
+
if (Date.now() > deadline) { stats.budgetExhausted = true; stats.truncated.push('time-budget'); break; }
|
|
156
|
+
if (size > limits.maxFileBytes) { stats.filesSkipped.push({ file: path.relative(base, file), reason: 'file-too-large' }); stats.truncated.push('file-too-large'); continue; }
|
|
157
|
+
if (totalBytes + size > limits.maxTotalBytes) { stats.filesSkipped.push({ file: path.relative(base, file), reason: 'total-size-limit' }); stats.truncated.push('total-size-limit'); continue; }
|
|
158
|
+
totalBytes += size;
|
|
159
|
+
const fstat = { name: path.relative(base, file) || path.basename(file), bytes: size, lines: 0, parsed: 0, failed: 0 };
|
|
160
|
+
stats.files.push(fstat);
|
|
161
|
+
const stream = fs.createReadStream(file, { encoding: 'utf8', highWaterMark: 1 << 20 });
|
|
162
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
163
|
+
try {
|
|
164
|
+
for await (const line of rl) {
|
|
165
|
+
if (signal?.aborted) { stats.aborted = true; break; }
|
|
166
|
+
if ((fstat.lines & 0x3ff) === 0 && Date.now() > deadline) { stats.budgetExhausted = true; stats.truncated.push('time-budget'); break; }
|
|
167
|
+
if (stats.lines >= limits.maxLines) { stats.truncated.push('line-limit'); break; }
|
|
168
|
+
stats.lines++; fstat.lines++;
|
|
169
|
+
if (line.length > limits.maxLineChars) { stats.oversizeLines++; stats.failed++; fstat.failed++; continue; }
|
|
170
|
+
const ev = parseLine(line);
|
|
171
|
+
if (ev === undefined) continue;
|
|
172
|
+
if (ev === null) { stats.failed++; fstat.failed++; continue; }
|
|
173
|
+
stats.parsed++; fstat.parsed++;
|
|
174
|
+
if (ev.method) stats.fieldCounts.method++;
|
|
175
|
+
if (ev.path != null) stats.fieldCounts.path++;
|
|
176
|
+
if (ev.query != null) stats.fieldCounts.query++;
|
|
177
|
+
if (ev.bodyParams != null) stats.fieldCounts.body++;
|
|
178
|
+
if (ev.headers != null) stats.fieldCounts.headers++;
|
|
179
|
+
if (ev.status != null) stats.fieldCounts.status++;
|
|
180
|
+
if (ev.authPresent != null) stats.fieldCounts.auth++;
|
|
181
|
+
if (ev.time) { stats.eventsWithTime++; if (!stats.windowStart || ev.time < stats.windowStart) stats.windowStart = ev.time; if (!stats.windowEnd || ev.time > stats.windowEnd) stats.windowEnd = ev.time; }
|
|
182
|
+
onEvent?.(ev);
|
|
183
|
+
}
|
|
184
|
+
} catch { stats.filesSkipped.push({ file: fstat.name, reason: 'read-error' }); } finally { rl.close(); stream.destroy(); }
|
|
185
|
+
if (stats.aborted || stats.budgetExhausted || stats.truncated.includes('line-limit')) break;
|
|
186
|
+
}
|
|
187
|
+
stats.truncated = [...new Set(stats.truncated)];
|
|
188
|
+
return stats;
|
|
189
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// The one evaluator behind every detection output. Validation (synthetic events), hunting (historical logs) and the
|
|
2
|
+
// rendered rules' described behavior all call `evaluateEvent`, so a rule that passes validation is the same rule that
|
|
3
|
+
// searches the logs. An event is the NORMALIZED request record produced by log-reader.js or by the validator.
|
|
4
|
+
//
|
|
5
|
+
// event = { time, method, path, query: {name: [values]}, bodyParams: {name: [values]} | null, headers: {name: value} | null,
|
|
6
|
+
// status, authPresent: boolean | null }
|
|
7
|
+
//
|
|
8
|
+
// A missing field is `undefined`/`null`, never an empty string: "the log did not record this" and "the value was empty" are
|
|
9
|
+
// different facts, and only the first makes an event undecidable.
|
|
10
|
+
import { PAYLOAD_CLASSES, matchesClass } from './payload-classes.js';
|
|
11
|
+
|
|
12
|
+
const norm = (p) => (p.length > 1 ? p.replace(/\/+$/, '') : p);
|
|
13
|
+
|
|
14
|
+
/** `/api/users/:id` and `/api/users/{id}` match any single non-empty segment in that position. */
|
|
15
|
+
export function pathMatches(template, actual) {
|
|
16
|
+
if (typeof actual !== 'string' || !actual.startsWith('/')) return false;
|
|
17
|
+
const t = norm(template).split('/');
|
|
18
|
+
const a = norm(actual.split('?')[0]).split('/');
|
|
19
|
+
if (t.length !== a.length) return false;
|
|
20
|
+
return t.every((seg, i) => (/^(?::[A-Za-z_]\w*|\{[A-Za-z_]\w*\})$/.test(seg) ? a[i].length > 0 : seg === a[i]));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Fields an event must have for a detection to be able to say "no" about it. */
|
|
24
|
+
export function requiredFields(ir) {
|
|
25
|
+
const need = new Set();
|
|
26
|
+
if (ir.endpoint) { need.add('method'); need.add('path'); }
|
|
27
|
+
if (ir.payloadClass === 'auth-bypass') { need.add('status'); need.add('auth'); return [...need]; }
|
|
28
|
+
const loc = ir.parameter?.location;
|
|
29
|
+
if (loc === 'body') need.add('body');
|
|
30
|
+
else if (loc === 'header' || loc === 'cookie') need.add('headers');
|
|
31
|
+
else if (loc === 'path') need.add('path');
|
|
32
|
+
else need.add('query');
|
|
33
|
+
if (!ir.endpoint) need.add('path');
|
|
34
|
+
return [...need];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function candidateValues(ir, event) {
|
|
38
|
+
const out = [];
|
|
39
|
+
const p = ir.parameter;
|
|
40
|
+
const take = (bag, name) => { for (const v of (bag?.[name] ?? [])) out.push([`${name}`, v]); };
|
|
41
|
+
if (!p) {
|
|
42
|
+
for (const [k, vs] of Object.entries(event.query ?? {})) for (const v of vs) out.push([`query:${k}`, v]);
|
|
43
|
+
for (const [k, vs] of Object.entries(event.bodyParams ?? {})) for (const v of vs) out.push([`body:${k}`, v]);
|
|
44
|
+
return { values: out, decidable: event.query != null };
|
|
45
|
+
}
|
|
46
|
+
if (p.location === 'query') { take(event.query, p.name); return { values: out.map(([k, v]) => [`query:${k}`, v]), decidable: event.query != null }; }
|
|
47
|
+
if (p.location === 'body') { take(event.bodyParams, p.name); return { values: out.map(([k, v]) => [`body:${k}`, v]), decidable: event.bodyParams != null }; }
|
|
48
|
+
if (p.location === 'header' || p.location === 'cookie') {
|
|
49
|
+
const v = event.headers?.[p.name.toLowerCase()];
|
|
50
|
+
return { values: v == null ? [] : [[`${p.location}:${p.name}`, v]], decidable: event.headers != null };
|
|
51
|
+
}
|
|
52
|
+
// path parameter: the segment where the template has the parameter
|
|
53
|
+
if (p.location === 'path' && ir.endpoint) {
|
|
54
|
+
const t = norm(ir.endpoint.path).split('/');
|
|
55
|
+
const a = norm(String(event.path).split('?')[0]).split('/');
|
|
56
|
+
t.forEach((seg, i) => { if (seg === `:${p.name}` || seg === `{${p.name}}`) out.push([`path:${p.name}`, a[i] ?? '']); });
|
|
57
|
+
}
|
|
58
|
+
return { values: out, decidable: typeof event.path === 'string' };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @returns {{match: boolean, undecidable: boolean, missing: string[], matchedOn: string|null, value: string|null}}
|
|
63
|
+
* `undecidable` means "this event lacks a field the detection needs", which is different from "this event does not match".
|
|
64
|
+
*/
|
|
65
|
+
export function evaluateEvent(ir, event) {
|
|
66
|
+
const res = { match: false, undecidable: false, missing: [], matchedOn: null, value: null };
|
|
67
|
+
if (ir.endpoint) {
|
|
68
|
+
if (!event.method) res.missing.push('method');
|
|
69
|
+
if (typeof event.path !== 'string') res.missing.push('path');
|
|
70
|
+
if (res.missing.length) { res.undecidable = true; return res; }
|
|
71
|
+
if (String(event.method).toUpperCase() !== ir.endpoint.method) return res;
|
|
72
|
+
if (!pathMatches(ir.endpoint.path, event.path)) return res;
|
|
73
|
+
} else if (typeof event.path !== 'string') { res.missing.push('path'); res.undecidable = true; return res; }
|
|
74
|
+
|
|
75
|
+
if (ir.payloadClass === 'auth-bypass') {
|
|
76
|
+
if (event.status == null) res.missing.push('status');
|
|
77
|
+
if (event.authPresent == null) res.missing.push('auth');
|
|
78
|
+
if (res.missing.length) { res.undecidable = true; return res; }
|
|
79
|
+
if (event.status >= 200 && event.status < 300 && event.authPresent === false) { res.match = true; res.matchedOn = 'unauthenticated-success'; res.value = `${event.status}`; }
|
|
80
|
+
return res;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!PAYLOAD_CLASSES[ir.payloadClass]) { res.undecidable = true; res.missing.push('payload-class'); return res; }
|
|
84
|
+
const { values, decidable } = candidateValues(ir, event);
|
|
85
|
+
for (const [where, v] of values) {
|
|
86
|
+
if (matchesClass(ir.payloadClass, v)) { res.match = true; res.matchedOn = where; res.value = String(v).slice(0, 200); return res; }
|
|
87
|
+
}
|
|
88
|
+
if (!decidable) { res.undecidable = true; res.missing.push(ir.parameter?.location === 'body' ? 'body' : ir.parameter?.location === 'header' || ir.parameter?.location === 'cookie' ? 'headers' : 'query'); }
|
|
89
|
+
return res;
|
|
90
|
+
}
|