@clear-capabilities/agentic-security-scanner 0.172.1 → 0.174.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 +26 -0
- package/bin/agentic-security.js +37 -0
- package/dist/119.index.js +197 -0
- package/dist/128.index.js +129 -0
- package/dist/1293.index.js +5689 -0
- package/dist/1351.index.js +133 -0
- package/dist/1387.index.js +271 -0
- package/dist/1437.index.js +23 -0
- package/dist/1445.index.js +110 -0
- package/dist/1505.index.js +151 -0
- package/dist/1557.index.js +293 -0
- package/dist/1621.index.js +311 -0
- package/dist/1751.index.js +160 -0
- package/dist/1765.index.js +727 -0
- package/dist/1861.index.js +690 -0
- package/dist/1898.index.js +116 -0
- package/dist/1906.index.js +433 -0
- package/dist/1920.index.js +1 -291
- package/dist/208.index.js +1631 -0
- package/dist/2199.index.js +0 -239
- package/dist/2223.index.js +159 -0
- package/dist/2530.index.js +152 -0
- package/dist/2565.index.js +116 -0
- package/dist/2574.index.js +127 -0
- package/dist/2620.index.js +345 -0
- package/dist/2773.index.js +98 -0
- package/dist/2775.index.js +32 -0
- package/dist/2966.index.js +171 -0
- package/dist/2972.index.js +87 -0
- package/dist/2991.index.js +131 -0
- package/dist/3063.index.js +833 -0
- package/dist/3075.index.js +227 -0
- package/dist/3121.index.js +297 -0
- package/dist/3314.index.js +263 -0
- package/dist/3480.index.js +1822 -0
- package/dist/3481.index.js +24 -0
- package/dist/3506.index.js +268 -0
- package/dist/3533.index.js +591 -0
- package/dist/3653.index.js +450 -0
- package/dist/3822.index.js +131 -0
- package/dist/3868.index.js +292 -0
- package/dist/3917.index.js +152 -0
- package/dist/3918.index.js +192 -0
- package/dist/4090.index.js +288 -0
- package/dist/4121.index.js +1190 -0
- package/dist/4142.index.js +0 -239
- package/dist/4336.index.js +436 -0
- package/dist/4387.index.js +532 -0
- package/dist/4413.index.js +302 -0
- package/dist/4476.index.js +4510 -0
- package/dist/4491.index.js +133 -0
- package/dist/4538.index.js +185 -0
- package/dist/4684.index.js +319 -0
- package/dist/4740.index.js +165 -0
- package/dist/4780.index.js +209 -0
- package/dist/4802.index.js +225 -0
- package/dist/4812.index.js +379 -0
- package/dist/4835.index.js +126 -0
- package/dist/494.index.js +226 -0
- package/dist/4991.index.js +549 -0
- package/dist/5199.index.js +305 -0
- package/dist/5216.index.js +109 -0
- package/dist/5439.index.js +301 -0
- package/dist/5443.index.js +97 -0
- package/dist/5496.index.js +377 -0
- package/dist/550.index.js +2 -1
- package/dist/5801.index.js +588 -0
- package/dist/5858.index.js +223 -0
- package/dist/5933.index.js +143 -0
- package/dist/601.index.js +23 -0
- package/dist/6173.index.js +273 -0
- package/dist/6291.index.js +1871 -0
- package/dist/6312.index.js +666 -0
- package/dist/634.index.js +16182 -0
- package/dist/644.index.js +87 -0
- package/dist/6508.index.js +283 -0
- package/dist/6560.index.js +645 -0
- package/dist/6576.index.js +422 -0
- package/dist/6579.index.js +271 -0
- package/dist/6745.index.js +305 -0
- package/dist/6818.index.js +21642 -0
- package/dist/6972.index.js +167 -0
- package/dist/6995.index.js +163 -0
- package/dist/7249.index.js +191 -0
- package/dist/7372.index.js +1197 -0
- package/dist/7546.index.js +541 -0
- package/dist/76.index.js +448 -0
- package/dist/7879.index.js +163 -0
- package/dist/8025.index.js +130 -0
- package/dist/8054.index.js +248 -0
- package/dist/8097.index.js +201 -0
- package/dist/8126.index.js +191 -0
- package/dist/8178.index.js +78 -0
- package/dist/818.index.js +212 -0
- package/dist/8382.index.js +93 -0
- package/dist/8408.index.js +261 -0
- package/dist/8540.index.js +174 -0
- package/dist/8635.index.js +802 -0
- package/dist/8688.index.js +410 -0
- package/dist/8699.index.js +779 -0
- package/dist/8891.index.js +457 -0
- package/dist/8916.index.js +885 -0
- package/dist/9146.index.js +170 -0
- package/dist/9168.index.js +841 -0
- package/dist/9292.index.js +369 -0
- package/dist/9382.index.js +135 -11
- package/dist/9422.index.js +866 -0
- package/dist/9440.index.js +453 -0
- package/dist/9479.index.js +152 -0
- package/dist/9481.index.js +220 -0
- package/dist/9539.index.js +100 -0
- package/dist/9547.index.js +103 -0
- package/dist/9560.index.js +41 -61
- package/dist/9692.index.js +90 -0
- package/dist/9800.index.js +213 -1
- package/dist/9899.index.js +138 -0
- package/dist/9981.index.js +206 -0
- package/dist/999.index.js +117 -0
- package/dist/agentic-security.mjs +27 -27
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +31 -6
- package/src/context-intelligence/candidates.js +2 -1
- package/src/context-intelligence/capabilities.js +2 -1
- package/src/context-intelligence/cli.js +2 -0
- package/src/context-intelligence/config.js +3 -1
- package/src/context-intelligence/controls.js +49 -1
- package/src/context-intelligence/counterexample.js +200 -0
- package/src/context-intelligence/cross-route.js +111 -0
- package/src/context-intelligence/findings.js +166 -0
- package/src/context-intelligence/invariants.js +197 -0
- package/src/context-intelligence/queue.js +6 -1
- package/src/context-intelligence/reachability.js +2 -2
- package/src/context-intelligence/review.js +68 -4
- package/src/dataflow/access-paths.js +11 -0
- package/src/dataflow/analysis-limits.js +66 -0
- package/src/dataflow/engine.js +282 -24
- package/src/dataflow/service-edges.js +174 -0
- package/src/dataflow/summaries.js +7 -1
- package/src/discovery/judge.js +2 -1
- package/src/engine.js +82 -14
- package/src/fix/apply-fix-service.js +55 -8
- package/src/fix/batch-journal.js +84 -0
- package/src/fix/exploit-replay.js +51 -0
- package/src/fix/footprint.js +65 -0
- package/src/fix/patch-policy.js +49 -0
- package/src/fix/secret-persistence.js +39 -0
- package/src/fix/test-commands.js +84 -0
- package/src/fix/verification-adapters.js +97 -0
- package/src/fix/verification-record.js +243 -0
- package/src/fix/verify-patch.js +89 -0
- package/src/ir/CLAUDE.md +5 -0
- package/src/ir/callgraph.js +23 -1
- package/src/ir/module-resolver.js +315 -0
- package/src/ir/parser-js.js +21 -2
- package/src/ir/semantic-graph.js +245 -0
- package/src/language/haskell-findings.js +2 -0
- package/src/language/haskell-guards.js +1 -1
- package/src/language/haskell-ir.js +3 -1
- package/src/language/haskell-models.js +94 -0
- package/src/language/haskell-security-rules.js +20 -0
- package/src/language/nixos-hardening.js +72 -0
- package/src/lineage/build-attested-correlation.js +63 -0
- package/src/lineage/deployment-identity.js +76 -0
- package/src/mcp/tools.js +13 -34
- package/src/pipeline/canonical-output.js +64 -0
- package/src/pipeline/detector-runner.js +7 -0
- package/src/pipeline/scan-health.js +14 -0
- package/src/pipeline/supervised-analyzers.js +125 -0
- package/src/pipeline/supervised-child.js +17 -0
- package/src/pipeline/telemetry.js +79 -0
- package/src/posture/accuracy-scorecard.js +98 -9
- package/src/posture/artifact-registry.js +5 -0
- package/src/posture/assurance/evidence-registry.js +215 -0
- package/src/posture/assurance/release-package-verify.js +135 -0
- package/src/posture/assurance/release-package.js +118 -0
- package/src/posture/assurance/release-verdict.js +94 -0
- package/src/posture/assurance/waiver.js +200 -0
- package/src/posture/compliance-truth.js +100 -0
- package/src/posture/evaluation/scan-child.js +3 -1
- package/src/posture/evaluation/why-missed.js +3 -1
- package/src/posture/exploitability-evidence.js +141 -0
- package/src/posture/fix-verify.js +4 -2
- package/src/posture/fleet/exposure-ranking.js +115 -0
- package/src/posture/fleet/service-chain-graph.js +150 -0
- package/src/posture/graph-reconciliation.js +154 -0
- package/src/posture/provenance/coverage.js +103 -0
- package/src/posture/provenance/git-evidence.js +84 -0
- package/src/posture/provenance/origin-resolver.js +7 -1
- package/src/posture/provenance/predicate-replay.js +14 -4
- package/src/posture/runtime-correlation.js +61 -15
- package/src/posture/runtime-explanation.js +116 -0
- package/src/posture/runtime-trace-ingest.js +194 -0
- package/src/posture/scan-checkpoint.js +38 -10
- package/src/posture/waf-ingest.js +7 -2
- package/src/report/index.js +4 -0
- package/src/report/oscal.js +38 -0
- package/src/report/perspectives.js +150 -0
- package/src/sast/_comment-strip.js +8 -1
- package/src/sast/ruby.js +26 -0
- package/src/sast/zip-slip.js +79 -0
- package/src/util/string-memo.js +38 -0
|
@@ -761,7 +761,9 @@ class FnLowerer {
|
|
|
761
761
|
// ── statements / control flow ──────────────────────────────────────────────
|
|
762
762
|
addCallNode(expr, ln) {
|
|
763
763
|
if (!expr) return;
|
|
764
|
-
|
|
764
|
+
// The call expression carries the line it was written on. The context that finally executes it (the last statement of a `do` handed to `get "/r" $`) is on an
|
|
765
|
+
// earlier line, and reporting that line would place the finding on the start of the whole handler instead of on the sink.
|
|
766
|
+
if (expr.kind === 'call') this.cfg.add('call', Number.isInteger(expr.line) && expr.line >= 1 ? expr.line : ln, { callee: expr.callee, args: expr.args, hs: expr.hs });
|
|
765
767
|
else if (expr.kind !== 'literal' && expr.kind !== 'ident') this.cfg.add('assign', ln, { target: `$e${this.tmp++}`, source: expr, hs: { demand: 'executed' } });
|
|
766
768
|
}
|
|
767
769
|
|
|
@@ -49,6 +49,28 @@ export const HS_PACKAGES = Object.freeze({
|
|
|
49
49
|
random: { modules: ['System.Random'], tested: ['1.2'] },
|
|
50
50
|
hxt: { modules: ['Text.XML.HXT.Core'], tested: ['9.3'] },
|
|
51
51
|
bytestring: { modules: ['Data.ByteString', 'Data.ByteString.Char8', 'Data.ByteString.Lazy'], tested: ['0.11', '0.12'] },
|
|
52
|
+
// Written from each library's documented API and checked only against the development cases in test/haskell/haskell-models-extended.test.js, which are
|
|
53
|
+
// plain source scans: no GHC build against these packages was run, so `tested` is empty and `modelStatus` reports every version as untested.
|
|
54
|
+
process_legacy: { modules: ['System.Cmd'], tested: [] },
|
|
55
|
+
turtle: { modules: ['Turtle'], tested: [] },
|
|
56
|
+
shelly: { modules: ['Shelly', 'Shelly.Lifted'], tested: [] },
|
|
57
|
+
hint: { modules: ['Language.Haskell.Interpreter'], tested: [] },
|
|
58
|
+
HDBC: { modules: ['Database.HDBC'], tested: [] },
|
|
59
|
+
'direct-sqlite': { modules: ['Database.SQLite3'], tested: [] },
|
|
60
|
+
'postgresql-libpq': { modules: ['Database.PostgreSQL.LibPQ'], tested: [] },
|
|
61
|
+
'persistent-backends': { modules: ['Database.Persist.Postgresql', 'Database.Persist.Sqlite', 'Database.Persist.MySQL', 'Database.Persist'], tested: [] },
|
|
62
|
+
req: { modules: ['Network.HTTP.Req'], tested: [] },
|
|
63
|
+
HTTP: { modules: ['Network.HTTP'], tested: [] },
|
|
64
|
+
snap: { modules: ['Snap.Core', 'Snap'], tested: [] },
|
|
65
|
+
Spock: { modules: ['Web.Spock', 'Web.Spock.Core'], tested: [] },
|
|
66
|
+
happstack: { modules: ['Happstack.Server', 'Happstack.Server.Internal.Types'], tested: [] },
|
|
67
|
+
network: { modules: ['Network.Socket.ByteString', 'Network.Socket.ByteString.Lazy'], tested: [] },
|
|
68
|
+
unix: { modules: ['System.Posix.Files'], tested: [] },
|
|
69
|
+
jwt: { modules: ['Web.JWT'], tested: [] },
|
|
70
|
+
'servant-auth-server': { modules: ['Servant.Auth.Server'], tested: [] },
|
|
71
|
+
'clientsession': { modules: ['Web.ClientSession'], tested: [] },
|
|
72
|
+
connection: { modules: ['Network.Connection'], tested: [] },
|
|
73
|
+
HsOpenSSL: { modules: ['OpenSSL.Session'], tested: [] },
|
|
52
74
|
});
|
|
53
75
|
|
|
54
76
|
// ── sources ─────────────────────────────────────────────────────────────────
|
|
@@ -85,6 +107,30 @@ src('Yesod', ['lookupGetParam', 'lookupGetParams', 'lookupPostParam', 'lookupPos
|
|
|
85
107
|
// HTTP responses are untrusted data too when they come back from a remote service the app calls
|
|
86
108
|
src('Network.HTTP.Simple', ['getResponseBody'], 'network', 'remote HTTP response', { package: 'http-conduit' });
|
|
87
109
|
src('Network.HTTP.Client', ['responseBody'], 'network', 'remote HTTP response', { package: 'http-client' });
|
|
110
|
+
// More file readers, and more web frameworks. Each reads a value the client (or the file's author) controls.
|
|
111
|
+
src('Data.ByteString.Char8', ['readFile'], 'file-read', 'file content', { package: 'bytestring' });
|
|
112
|
+
src('Data.ByteString.Lazy.Char8', ['readFile'], 'file-read', 'file content', { package: 'bytestring' });
|
|
113
|
+
src('Data.Text.Lazy.IO', ['readFile'], 'file-read', 'file content', { package: 'text' });
|
|
114
|
+
src('Network.Socket.ByteString', ['recv', 'recvFrom'], 'network', 'bytes read from a socket', { package: 'network' });
|
|
115
|
+
src('Network.Socket.ByteString.Lazy', ['recv', 'getContents'], 'network', 'bytes read from a socket', { package: 'network' });
|
|
116
|
+
src('Network.HTTP', ['getResponseBody'], 'network', 'remote HTTP response', { package: 'HTTP' });
|
|
117
|
+
// Snap
|
|
118
|
+
src('Snap.Core', ['getParam', 'getPostParam', 'getQueryParam', 'getParams', 'getPostParams', 'getQueryParams'], 'url-param', 'snap request parameter', { package: 'snap', framework: 'snap' });
|
|
119
|
+
src('Snap.Core', ['readRequestBody', 'getRequestBody'], 'http-body', 'snap request body', { package: 'snap', framework: 'snap' });
|
|
120
|
+
src('Snap', ['getParam', 'getPostParam', 'getQueryParam', 'getParams', 'getPostParams', 'getQueryParams'], 'url-param', 'snap request parameter', { package: 'snap', framework: 'snap' });
|
|
121
|
+
// Spock
|
|
122
|
+
src('Web.Spock', ['param', "param'", 'params'], 'url-param', 'spock request parameter', { package: 'Spock', framework: 'spock' });
|
|
123
|
+
src('Web.Spock', ['header', 'cookie', 'cookies'], 'header', 'spock request header or cookie', { package: 'Spock', framework: 'spock' });
|
|
124
|
+
src('Web.Spock', ['body', 'jsonBody', "jsonBody'", 'files'], 'http-body', 'spock request body', { package: 'Spock', framework: 'spock' });
|
|
125
|
+
src('Web.Spock.Core', ['param', "param'", 'params'], 'url-param', 'spock request parameter', { package: 'Spock', framework: 'spock' });
|
|
126
|
+
// Happstack
|
|
127
|
+
src('Happstack.Server', ['look', 'lookText', "lookText'", 'lookBS', 'lookRead', 'lookPairs', 'lookPairsBS', 'lookCookieValue'], 'url-param', 'happstack request input', { package: 'happstack', framework: 'happstack' });
|
|
128
|
+
// Yesod: the whole request value and its body
|
|
129
|
+
src('Yesod.Core', ['getRequest', 'rawRequestBody', 'runRequestBody', 'lookupFile', 'lookupFiles'], 'http-body', 'yesod request', { package: 'yesod', framework: 'yesod' });
|
|
130
|
+
src('Yesod', ['getRequest', 'rawRequestBody', 'runRequestBody', 'lookupFile', 'lookupFiles'], 'http-body', 'yesod request', { package: 'yesod', framework: 'yesod' });
|
|
131
|
+
// Scotty: the underlying WAI request
|
|
132
|
+
src('Web.Scotty', ['request'], 'http-body', 'scotty WAI request', { package: 'scotty', framework: 'scotty' });
|
|
133
|
+
src('Web.Scotty.Trans', ['request'], 'http-body', 'scotty WAI request', { package: 'scotty', framework: 'scotty' });
|
|
88
134
|
|
|
89
135
|
// ── sinks ───────────────────────────────────────────────────────────────────
|
|
90
136
|
// family: the taint family the existing engine already uses (cmd | sql | path | url | xss)
|
|
@@ -161,6 +207,52 @@ sink('Prelude', ['putStrLn', 'putStr'], XSS_TEXT);
|
|
|
161
207
|
sink('System.IO', ['putStrLn', 'putStr'], XSS_TEXT);
|
|
162
208
|
sink('Data.Text.IO', ['putStrLn', 'putStr'], { ...XSS_TEXT, package: 'text' });
|
|
163
209
|
|
|
210
|
+
// ── more sinks (written from the libraries' documented behaviour) ───────────────────────────────────────────────────────
|
|
211
|
+
// CWE-78. The legacy System.Cmd module, Turtle and Shelly. A shell string is parsed by /bin/sh; a program name is not, but a tainted one is arbitrary execution.
|
|
212
|
+
sink('System.Cmd', ['system'], { family: 'cmd', cwe: 'CWE-78', severity: 'critical', argIndex: 0, shell: true, package: 'process_legacy', vuln: 'OS Command Injection (shell string)', remediation: 'Do not build a shell command from input. Use `proc`/`callProcess` with a fixed program and pass each value as its own argument.' });
|
|
213
|
+
sink('System.Cmd', ['rawSystem'], { family: 'cmd', cwe: 'CWE-78', severity: 'high', argIndex: 0, shell: false, package: 'process_legacy', vuln: 'OS Command Injection (program name)', remediation: 'Use a fixed program name; never take it from input.' });
|
|
214
|
+
sink('System.Cmd', ['rawSystem'], { family: 'cmd', cwe: 'CWE-88', severity: 'medium', argIndex: 1, shell: false, argv: true, package: 'process_legacy', vuln: 'Argument Injection (process arguments)', remediation: 'Validate each argument against an allow-list, and put `--` before values that could start with a dash.' });
|
|
215
|
+
sink('Turtle', ['shell', 'inshell', 'shells', 'inshells'], { family: 'cmd', cwe: 'CWE-78', severity: 'critical', argIndex: 0, shell: true, package: 'turtle', vuln: 'OS Command Injection (Turtle shell command)', remediation: 'Use `proc`/`procs` with a fixed program and a list of arguments, not a shell string built from input.' });
|
|
216
|
+
sink('Turtle', ['proc', 'procs', 'inproc', 'inprocs'], { family: 'cmd', cwe: 'CWE-78', severity: 'high', argIndex: 0, shell: false, package: 'turtle', vuln: 'OS Command Injection (program name)', remediation: 'Use a fixed program name; never take it from input.' });
|
|
217
|
+
sink('Turtle', ['proc', 'procs', 'inproc', 'inprocs'], { family: 'cmd', cwe: 'CWE-88', severity: 'medium', argIndex: 1, shell: false, argv: true, package: 'turtle', vuln: 'Argument Injection (process arguments)', remediation: 'Validate each argument against an allow-list, and put `--` before values that could start with a dash.' });
|
|
218
|
+
for (const mod of ['Shelly', 'Shelly.Lifted']) {
|
|
219
|
+
sink(mod, ['run', 'run_', 'runFoldLines', 'command', 'command_'], { family: 'cmd', cwe: 'CWE-78', severity: 'high', argIndex: 0, shell: false, package: 'shelly', vuln: 'OS Command Injection (program name)', remediation: 'Use a fixed program name; never take it from input.' });
|
|
220
|
+
sink(mod, ['run', 'run_', 'runFoldLines'], { family: 'cmd', cwe: 'CWE-88', severity: 'medium', argIndex: 1, shell: false, argv: true, package: 'shelly', vuln: 'Argument Injection (process arguments)', remediation: 'Validate each argument against an allow-list, and put `--` before values that could start with a dash.' });
|
|
221
|
+
sink(mod, ['bash', 'bash_', 'bashPipeFail'], { family: 'cmd', cwe: 'CWE-78', severity: 'critical', argIndex: 0, shell: true, package: 'shelly', vuln: 'OS Command Injection (bash script text)', remediation: 'Do not build a script from input. Call the program directly with `run` and a list of arguments.' });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// CWE-94. Evaluating Haskell source text at run time (the hint package) runs whatever code the text holds.
|
|
225
|
+
sink('Language.Haskell.Interpreter', ['interpret', 'eval', 'runStmt'], { family: 'code', cwe: 'CWE-94', severity: 'critical', argIndex: 0, package: 'hint', vuln: 'Code Injection (Haskell source evaluated at run time)', remediation: 'Do not evaluate text that came from a user. Parse the input into a closed data type and interpret that instead.' });
|
|
226
|
+
|
|
227
|
+
// CWE-89. HDBC, direct-sqlite, postgresql-libpq, and the persistent backends that re-export rawSql. The query TEXT is the sink; a `?` placeholder with bound values is the safe form.
|
|
228
|
+
sink('Database.HDBC', ['run', 'quickQuery', "quickQuery'", 'prepare', 'runRaw'], { ...SQL, argIndex: 1, package: 'HDBC', vuln: 'SQL Injection (HDBC query text)' });
|
|
229
|
+
sink('Database.SQLite3', ['exec', 'prepare'], { ...SQL, argIndex: 1, package: 'direct-sqlite', vuln: 'SQL Injection (SQLite statement text)' });
|
|
230
|
+
sink('Database.PostgreSQL.LibPQ', ['exec', 'execParams', 'prepare'], { ...SQL, argIndex: 1, package: 'postgresql-libpq', vuln: 'SQL Injection (libpq command text)' });
|
|
231
|
+
for (const mod of ['Database.Persist.Postgresql', 'Database.Persist.Sqlite', 'Database.Persist.MySQL']) sink(mod, ['rawSql', 'rawExecute', 'rawQuery'], { ...SQL, argIndex: 0, package: 'persistent-backends', vuln: 'SQL Injection (raw SQL text)' });
|
|
232
|
+
|
|
233
|
+
// CWE-918. Req builds a URL from typed pieces, so the HOST is the attacker-steered part; HTTP's request constructors take the URL text.
|
|
234
|
+
sink('Network.HTTP.Req', ['https', 'http'], { ...SSRF, argIndex: 0, package: 'req', vuln: 'Server-Side Request Forgery (request host)' });
|
|
235
|
+
sink('Network.HTTP', ['getRequest', 'postRequest', 'headRequest', 'simpleHTTP'], { ...SSRF, argIndex: 0, package: 'HTTP' });
|
|
236
|
+
|
|
237
|
+
// CWE-601. Redirecting to a location taken from the request sends the user to a site the attacker names.
|
|
238
|
+
const REDIRECT = { family: 'redirect', cwe: 'CWE-601', severity: 'medium', argIndex: 0, vuln: 'Open Redirect', remediation: 'Redirect only to a fixed path or to a value found in an allow-list; never to a request value as given.' };
|
|
239
|
+
sink('Web.Scotty', ['redirect'], { ...REDIRECT, package: 'scotty', framework: 'scotty' });
|
|
240
|
+
sink('Web.Scotty.Trans', ['redirect'], { ...REDIRECT, package: 'scotty', framework: 'scotty' });
|
|
241
|
+
sink('Yesod.Core', ['redirect'], { ...REDIRECT, package: 'yesod', framework: 'yesod' });
|
|
242
|
+
sink('Yesod.Core', ['redirectWith'], { ...REDIRECT, argIndex: 1, package: 'yesod', framework: 'yesod' });
|
|
243
|
+
sink('Yesod', ['redirect'], { ...REDIRECT, package: 'yesod', framework: 'yesod' });
|
|
244
|
+
sink('Snap.Core', ['redirect', "redirect'"], { ...REDIRECT, package: 'snap', framework: 'snap' });
|
|
245
|
+
sink('Snap', ['redirect', "redirect'"], { ...REDIRECT, package: 'snap', framework: 'snap' });
|
|
246
|
+
sink('Web.Spock', ['redirect'], { ...REDIRECT, package: 'Spock', framework: 'spock' });
|
|
247
|
+
sink('Happstack.Server', ['seeOther', 'tempRedirect', 'movedPermanently'], { ...REDIRECT, package: 'happstack', framework: 'happstack' });
|
|
248
|
+
|
|
249
|
+
// CWE-22. The same path operations through the other string types, and the POSIX file calls.
|
|
250
|
+
sink('Data.ByteString.Char8', ['readFile', 'writeFile', 'appendFile'], { family: 'path', cwe: 'CWE-22', severity: 'high', argIndex: 0, package: 'bytestring', vuln: 'Path Traversal', remediation: 'Confirm the canonical path stays under an allowed directory before opening it.' });
|
|
251
|
+
sink('Data.ByteString.Lazy.Char8', ['readFile', 'writeFile', 'appendFile'], { family: 'path', cwe: 'CWE-22', severity: 'high', argIndex: 0, package: 'bytestring', vuln: 'Path Traversal', remediation: 'Confirm the canonical path stays under an allowed directory before opening it.' });
|
|
252
|
+
sink('Data.Text.Lazy.IO', ['readFile', 'writeFile', 'appendFile'], { family: 'path', cwe: 'CWE-22', severity: 'high', argIndex: 0, package: 'text', vuln: 'Path Traversal', remediation: 'Confirm the canonical path stays under an allowed directory before opening it.' });
|
|
253
|
+
sink('System.Posix.Files', ['removeLink', 'createSymbolicLink', 'setFileMode', 'setOwnerAndGroup', 'readSymbolicLink'], { family: 'path', cwe: 'CWE-22', severity: 'high', argIndex: 0, package: 'unix', vuln: 'Path Traversal (file operation)', remediation: 'Confirm the canonical path stays under an allowed directory before the operation.' });
|
|
254
|
+
sink('System.Posix.Files', ['rename'], { family: 'path', cwe: 'CWE-22', severity: 'high', argIndex: 'all', package: 'unix', vuln: 'Path Traversal (rename)', remediation: 'Confirm both canonical paths stay under an allowed directory.' });
|
|
255
|
+
|
|
164
256
|
// ── sanitizers (context-specific; `appliesTo` is the family each one actually neutralises) ──
|
|
165
257
|
const SAN = [];
|
|
166
258
|
const san = (module, names, appliesTo, note, extra = {}) => { for (const e of E(module, names)) SAN.push({ ...e, appliesTo, note, ...extra }); };
|
|
@@ -219,6 +311,8 @@ export const HS_RULE_APIS = Object.freeze([
|
|
|
219
311
|
...E('Data.Text.IO', ['getContents', 'hGetContents']), ...E('Data.Text.Lazy.IO', ['getContents', 'hGetContents']),
|
|
220
312
|
...E('Data.Text', ['replicate', 'take']), ...E('Data.Vector', ['replicate']),
|
|
221
313
|
...E('Web.Cookie', ['defaultSetCookie']),
|
|
314
|
+
...E('Web.JWT', ['hmacSecret', 'secret']), ...E('Servant.Auth.Server', ['fromSecret']), ...E('Web.ClientSession', ['initKey']),
|
|
315
|
+
...E('Network.HTTP.Client.TLS', ['mkManagerSettings']), ...E('OpenSSL.Session', ['contextSetVerificationMode']),
|
|
222
316
|
...E('Text.XML.HXT.Core', ['withSubstDTDEntities']),
|
|
223
317
|
...E('System.IO.Unsafe', ['unsafePerformIO', 'unsafeInterleaveIO', 'unsafeDupablePerformIO']),
|
|
224
318
|
...E('Unsafe.Coerce', ['unsafeCoerce']),
|
|
@@ -33,6 +33,8 @@ export const RULES = Object.freeze({
|
|
|
33
33
|
'hs-unbounded-input-read': { cwe: 'CWE-770', severity: 'medium', family: 'resource-exhaustion', vuln: 'Whole input read into memory without a size limit', remediation: 'Bound the read (`BL.take n`, `hGetSome`, a request-size limit) before the data is consumed.' },
|
|
34
34
|
'hs-unbounded-allocation': { cwe: 'CWE-770', severity: 'medium', family: 'resource-exhaustion', vuln: 'Allocation sized by parsed caller text without an upper bound', remediation: 'Clamp the size (`min limit n`) before allocating.' },
|
|
35
35
|
'hs-partial-function-on-input': { cwe: 'CWE-248', severity: 'low', family: 'unhandled-exception', vuln: 'Partial function applied to caller-supplied text', remediation: 'Use the total variant (`readMaybe`, `listToMaybe`, pattern matching) and handle the failure.' },
|
|
36
|
+
'hs-tls-validation-disabled': { cwe: 'CWE-295', severity: 'high', family: 'tls-validation', vuln: 'TLS certificate validation is disabled', remediation: 'Do not disable certificate validation. Use the default settings (`tlsManagerSettings`, `TLSSettingsSimple False False False`, `VerifyPeer`) and, for a private certificate authority, add that authority to the trust store instead.' },
|
|
37
|
+
'hs-hardcoded-secret': { cwe: 'CWE-798', severity: 'high', family: 'hardcoded-credentials', vuln: 'Signing or encryption secret written in the source', remediation: 'Read the secret from the environment or a secret store at start-up, and rotate the one that was committed: it is now in the repository history.' },
|
|
36
38
|
'hs-unsafe-io-boundary': { cwe: 'CWE-676', severity: 'low', family: 'unsafe-boundary', vuln: 'Unsafe IO / coercion boundary', remediation: 'Confine `unsafePerformIO`/`unsafeCoerce` to audited, well-tested wrappers.' },
|
|
37
39
|
});
|
|
38
40
|
|
|
@@ -56,6 +58,11 @@ const PBKDF2_CALLS = new Set(['Crypto.KDF.PBKDF2.generate', 'Crypto.KDF.PBKDF2.f
|
|
|
56
58
|
const STRONG_KDF = /^(?:Crypto\.KDF\.BCrypt\.|Crypto\.Argon2\.|Crypto\.Scrypt\.|Crypto\.KDF\.Scrypt\.|Crypto\.PasswordStore\.|Crypto\.BCrypt\.)/;
|
|
57
59
|
const UNSAFE_CALLS = { 'System.IO.Unsafe.unsafePerformIO': 'unsafePerformIO', 'System.IO.Unsafe.unsafeInterleaveIO': 'unsafeInterleaveIO', 'System.IO.Unsafe.unsafeDupablePerformIO': 'unsafeDupablePerformIO', 'Unsafe.Coerce.unsafeCoerce': 'unsafeCoerce' };
|
|
58
60
|
|
|
61
|
+
// TLS: the first field of TLSSettingsSimple (Network.Connection) is settingDisableCertificateValidation, and `VerifyNone` (OpenSSL.Session) skips peer verification.
|
|
62
|
+
// The IR keeps a constructor's name in `hs.con` but not the module it came from, so a constructor counts only in a file that imports the module defining it.
|
|
63
|
+
// Calls whose argument is a key or signing secret.
|
|
64
|
+
const SECRET_KEY_CALLS = new Set(['Web.JWT.hmacSecret', 'Web.JWT.secret', 'Servant.Auth.Server.fromSecret', 'Web.ClientSession.initKey']);
|
|
65
|
+
const isTrueLit = (e) => !!e && ((e.kind === 'literal' && /^true$/i.test(String(e.value))) || (e.kind === 'ident' && /(?:^|\.)True$/.test(e.name || '')));
|
|
59
66
|
const TIME_SOURCES = new Set(['Data.Time.Clock.POSIX.getPOSIXTime', 'Data.Time.Clock.getCurrentTime', 'System.CPUTime.getCPUTime']);
|
|
60
67
|
const SECURITY_FN_SUBSTR = /(?:token|nonce|secret|otp|csrf|session|salt|apikey|resetcode|passcode|verification)/i;
|
|
61
68
|
// Short credential words that would over-match as substrings (`spin`, `mapping`, `keyboard`): matched as whole camelCase / snake_case words.
|
|
@@ -215,6 +222,16 @@ export function analyzeHaskellRules(files, opts = {}) {
|
|
|
215
222
|
}
|
|
216
223
|
continue;
|
|
217
224
|
}
|
|
225
|
+
// TLS certificate validation switched off, by constructor
|
|
226
|
+
if (e.hs && e.hs.con) {
|
|
227
|
+
const importsFrom = (mods) => (fir.imports || []).some((im) => mods.includes(im.module));
|
|
228
|
+
if (e.hs.con === 'TLSSettingsSimple' && importsFrom(['Network.Connection', 'Network.Connection.Types'])) {
|
|
229
|
+
const positional = e.kind === 'array' && isTrueLit(e.elements[0]);
|
|
230
|
+
const named = e.kind === 'object' && (e.props || []).some((p) => p && p.key === 'settingDisableCertificateValidation' && isTrueLit(p.value));
|
|
231
|
+
if (positional || named) emit('hs-tls-validation-disabled', file, e.line || node.line, { detail: 'TLSSettingsSimple with settingDisableCertificateValidation = True', evidence: { setting: 'settingDisableCertificateValidation' } });
|
|
232
|
+
}
|
|
233
|
+
if (e.hs.con === 'VerifyNone' && e.kind === 'literal' && importsFrom(['OpenSSL.Session'])) emit('hs-tls-validation-disabled', file, e.line || node.line, { detail: 'OpenSSL verification mode VerifyNone', evidence: { mode: 'VerifyNone' } });
|
|
234
|
+
}
|
|
218
235
|
// `xs !! n` raises on a short list: a partial function, written as an operator, applied to caller-supplied text
|
|
219
236
|
if (e.kind === 'binary' && e.op === '!!' && e.left && mentionsText(e.left)) {
|
|
220
237
|
emit('hs-partial-function-on-input', file, e.line || node.line, { detail: '!! on a caller-supplied list', evidence: { partial: '!!' } });
|
|
@@ -225,6 +242,9 @@ export function analyzeHaskellRules(files, opts = {}) {
|
|
|
225
242
|
const line = e.line || node.line;
|
|
226
243
|
const args = e.args || [];
|
|
227
244
|
|
|
245
|
+
// TLS certificate validation switched off
|
|
246
|
+
// a signing/encryption secret that is a constant in the source
|
|
247
|
+
if (SECRET_KEY_CALLS.has(c) && args[0] && isLiteralLike(args[0]) && !(args[0].kind === 'literal' && String(args[0].value).length < 8)) emit('hs-hardcoded-secret', file, line, { detail: `${c} given a constant`, confidence: 0.85, evidence: { call: c } });
|
|
228
248
|
// weak hash
|
|
229
249
|
if (WEAK_HASH_CALLS.has(c) || (c === 'Crypto.Hash.hashWith' && args[0] && args[0].kind === 'literal' && WEAK_ALGO_CONS.has(args[0].value))) {
|
|
230
250
|
const pw = args.some((a) => [...names(a)].some((n) => PASSWORD_VAR.test(n)));
|
|
@@ -54,6 +54,11 @@ export const HARDENING_RULES = Object.freeze({
|
|
|
54
54
|
'tls-not-enforced': { family: 'tls-runtime', cwe: 'CWE-319', severity: 'medium', ruleVersion: 1, vuln: 'Virtual host explicitly serves cleartext HTTP (forceSSL = false)', why: 'forceSSL = false is a deliberate choice to keep the plain-HTTP listener open for the whole host, so credentials and session data cross the network unencrypted.', fix: 'Set `forceSSL = true` (with `enableACME = true` or a certificate) so HTTP is redirected to HTTPS.' },
|
|
55
55
|
'container-privileged': { family: 'container-declaration', cwe: 'CWE-250', severity: 'high', ruleVersion: 1, vuln: 'OCI container is declared privileged or with a dangerous mount', why: '--privileged, SYS_ADMIN/ALL capabilities and the container runtime socket give the container control of the host. Image layers are NOT scanned.', fix: 'Remove --privileged and the runtime socket mount; add only the specific capability the workload needs.' },
|
|
56
56
|
'container-host-network': { family: 'container-declaration', cwe: 'CWE-668', severity: 'medium', ruleVersion: 1, vuln: 'OCI container shares the host network namespace', why: 'Host networking removes network isolation, so the container can reach and bind host-local services. Image layers are NOT scanned.', fix: 'Use the default bridge and publish only the needed ports on a specific address.' },
|
|
57
|
+
'user-root-equivalent-group': { family: 'privilege-escalation', cwe: 'CWE-269', severity: 'medium', ruleVersion: 1, vuln: 'A user is a member of a group that is equivalent to root', why: 'Membership of docker or lxd lets the user start a container with the host filesystem mounted, and membership of disk gives raw read and write access to the block devices. Each is root on the machine, without sudo and without a password prompt.', fix: 'Remove the group. Use rootless containers (virtualisation.docker.rootless, podman) or run the tool through sudo with a narrow rule.' },
|
|
58
|
+
'user-uid-zero': { family: 'privilege-escalation', cwe: 'CWE-250', severity: 'high', ruleVersion: 1, vuln: 'A user other than root has user id 0', why: 'User id 0 is root: the kernel does not look at the name. A second account with uid 0 is a root login that audit logs, sudo rules and per-user controls do not treat as root.', fix: 'Remove the uid assignment so the account gets a normal id, or use sudo for the cases that need root.' },
|
|
59
|
+
'getty-autologin': { family: 'login-access', cwe: 'CWE-306', severity: 'high', ruleVersion: 1, vuln: 'The console logs a user in without a password', why: 'services.getty.autologinUser starts a session on every virtual console with no authentication. For root that is a root shell for anyone with the keyboard, a serial line or a hypervisor console.', fix: 'Remove services.getty.autologinUser. Keep autologin to throwaway virtual machines and tests.' },
|
|
60
|
+
'display-autologin': { family: 'login-access', cwe: 'CWE-306', severity: 'low', ruleVersion: 1, vuln: 'The display manager logs a user in without a password', why: 'Automatic login skips authentication at the graphical login, so anyone who can power the machine on gets the session. Disk encryption is the only remaining barrier.', fix: 'Disable autoLogin, or keep it only where physical access is controlled and the disk is encrypted.' },
|
|
61
|
+
'pam-null-password': { family: 'login-access', cwe: 'CWE-258', severity: 'high', ruleVersion: 1, vuln: 'A PAM service accepts accounts with an empty password', why: 'allowNullPassword lets an account with no password log in through this service with an empty answer, which turns every passwordless account into an open login.', fix: 'Remove allowNullPassword (it defaults to false) and give every account a password hash or a key.' },
|
|
57
62
|
});
|
|
58
63
|
|
|
59
64
|
/** Which rules explain each privileged entry-point kind (reconciliation join). */
|
|
@@ -368,6 +373,41 @@ export function analyzeNixosHardening(opts, extra = {}) {
|
|
|
368
373
|
}
|
|
369
374
|
}
|
|
370
375
|
|
|
376
|
+
// ── accounts and console login ──
|
|
377
|
+
// Root-equivalent groups: each of these gives the member control of the host without sudo.
|
|
378
|
+
const ROOT_EQUIVALENT_GROUPS = new Map([['docker', 'start a container with the host root mounted'], ['lxd', 'start a privileged container with the host root mounted'], ['disk', 'read and write the raw block devices']]);
|
|
379
|
+
for (const o of cfg.options.filter((x) => x.namespace === 'nixos' && /^users\.users\.[^.]+\.extraGroups$/.test(x.path))) {
|
|
380
|
+
const name = o.path.split('.')[2];
|
|
381
|
+
if (name === 'root') continue;
|
|
382
|
+
const v = view(note(o, 'accounts'));
|
|
383
|
+
const items = v.state === 'known' ? asList(v.value) : v.state === 'conditional' ? asList(v.definiteItems) : [];
|
|
384
|
+
const bad = items.filter((g) => typeof g === 'string' && ROOT_EQUIVALENT_GROUPS.has(g));
|
|
385
|
+
if (bad.length) addFinding('user-root-equivalent-group', { anchor: anchorOf(o), scope: `users.users.${name}`, state: v.state === 'conditional' ? 'conditional' : 'active', uncertain: !v.definite, evidence: [evidenceOf(o)], extra: { groups: bad, effect: bad.map((g) => `${g}: ${ROOT_EQUIVALENT_GROUPS.get(g)}`) } });
|
|
386
|
+
}
|
|
387
|
+
for (const o of cfg.options.filter((x) => x.namespace === 'nixos' && /^users\.users\.[^.]+\.uid$/.test(x.path))) {
|
|
388
|
+
const name = o.path.split('.')[2];
|
|
389
|
+
if (name === 'root') continue;
|
|
390
|
+
const j = judge(note(o, 'accounts'), (x) => x === 0);
|
|
391
|
+
if (j === 'yes' || j === 'conditional') addFinding('user-uid-zero', { anchor: anchorOf(o), scope: `users.users.${name}`, state: j === 'conditional' ? 'conditional' : 'active', uncertain: !view(o).definite, evidence: [evidenceOf(o)] });
|
|
392
|
+
}
|
|
393
|
+
const gettyRes = note(nixos('services.getty.autologinUser'), 'login');
|
|
394
|
+
const gettyV = view(gettyRes);
|
|
395
|
+
if (gettyV.state === 'known' && typeof gettyV.value === 'string' && gettyV.value) {
|
|
396
|
+
addFinding('getty-autologin', { severity: gettyV.value === 'root' ? 'high' : 'low', anchor: anchorOf(gettyRes), scope: 'services.getty.autologinUser', state: 'active', uncertain: !gettyV.definite, evidence: [evidenceOf(gettyRes)], extra: { user: gettyV.value } });
|
|
397
|
+
}
|
|
398
|
+
for (const base of ['services.displayManager.autoLogin', 'services.xserver.displayManager.autoLogin']) {
|
|
399
|
+
const en = note(nixos(`${base}.enable`), 'login');
|
|
400
|
+
const j = judge(en, (x) => x === true);
|
|
401
|
+
if (j === 'yes' || j === 'conditional') {
|
|
402
|
+
const userV = view(nixos(`${base}.user`));
|
|
403
|
+
addFinding('display-autologin', { severity: userV.state === 'known' && userV.value === 'root' ? 'high' : 'low', anchor: anchorOf(en), scope: base, state: j === 'conditional' ? 'conditional' : 'active', uncertain: !view(en).definite, evidence: [evidenceOf(en)], extra: userV.state === 'known' ? { user: userV.value } : {} });
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
for (const o of cfg.options.filter((x) => x.namespace === 'nixos' && /^security\.pam\.services\.[^.]+\.allowNullPassword$/.test(x.path))) {
|
|
407
|
+
const j = judge(note(o, 'login'), (x) => x === true);
|
|
408
|
+
if (j === 'yes' || j === 'conditional') addFinding('pam-null-password', { anchor: anchorOf(o), scope: o.path.replace(/\.allowNullPassword$/, ''), state: j === 'conditional' ? 'conditional' : 'active', uncertain: !view(o).definite, evidence: [evidenceOf(o)] });
|
|
409
|
+
}
|
|
410
|
+
|
|
371
411
|
// ── databases / listeners ──
|
|
372
412
|
if (hasDefs('services.postgresql.')) {
|
|
373
413
|
const enableRes = nixos('services.postgresql.enable');
|
|
@@ -398,6 +438,38 @@ export function analyzeNixosHardening(opts, extra = {}) {
|
|
|
398
438
|
inventory.push({ kind: 'declared-service', name: 'nginx', state, listening: state === 'active' ? 'unknown' : state === 'conditional' ? 'unknown' : 'no', ports: [], ingress: 'unknown', reachability: { internet: 'unknown', reason: 'listen addresses are generated by the module and external topology is not modelled' }, evidence: [evidenceOf(enableRes)] });
|
|
399
439
|
}
|
|
400
440
|
|
|
441
|
+
// ── other services that choose their bind address ──
|
|
442
|
+
// The same decision postgresql makes above. Only an EXPLICIT wildcard (or, for redis, an explicit null, which the module defines as "every interface") is reported:
|
|
443
|
+
// an unset option keeps the module's own default, which the catalog does not record for these services and which is never guessed.
|
|
444
|
+
const WILDCARD = (x, nullIsAll) => (x === null ? nullIsAll === true : typeof x === 'string' && x.split(/[\s,]+/).filter(Boolean).some((a) => a === '*' || a === '0.0.0.0' || a === '::' || a === '[::]'));
|
|
445
|
+
const LISTENERS = [
|
|
446
|
+
{ name: 'redis', pattern: /^services\.redis\.servers\.([^.]+)\.bind$/, enable: (m) => `services.redis.servers.${m[1]}.enable`, portOpt: (m) => `services.redis.servers.${m[1]}.port`, port: 6379, nullIsAll: true, scope: (m) => `services.redis.servers.${m[1]}` },
|
|
447
|
+
{ name: 'mysql', pattern: /^services\.mysql\.settings\.mysqld\.(?:bind-address|bind_address)$/, enable: () => 'services.mysql.enable', port: 3306, scope: () => 'services.mysql' },
|
|
448
|
+
{ name: 'mongodb', pattern: /^services\.mongodb\.bind_ip$/, enable: () => 'services.mongodb.enable', port: 27017, scope: () => 'services.mongodb' },
|
|
449
|
+
{ name: 'memcached', pattern: /^services\.memcached\.listen$/, enable: () => 'services.memcached.enable', portOpt: () => 'services.memcached.port', port: 11211, scope: () => 'services.memcached' },
|
|
450
|
+
{ name: 'elasticsearch', pattern: /^services\.elasticsearch\.listenAddress$/, enable: () => 'services.elasticsearch.enable', portOpt: () => 'services.elasticsearch.port', port: 9200, scope: () => 'services.elasticsearch' },
|
|
451
|
+
{ name: 'grafana', pattern: /^services\.grafana\.settings\.server\.http_addr$/, enable: () => 'services.grafana.enable', port: 3000, scope: () => 'services.grafana' },
|
|
452
|
+
];
|
|
453
|
+
for (const L of LISTENERS) {
|
|
454
|
+
for (const o of cfg.options.filter((x) => x.namespace === 'nixos' && L.pattern.test(x.path))) {
|
|
455
|
+
const m = L.pattern.exec(o.path);
|
|
456
|
+
const listenRes = note(o, 'listener');
|
|
457
|
+
const wild = judge(listenRes, (x) => WILDCARD(x, L.nullIsAll));
|
|
458
|
+
if (wild !== 'yes' && wild !== 'conditional') continue;
|
|
459
|
+
const enableRes = note(nixos(L.enable(m)), 'listener');
|
|
460
|
+
const state = stateOf(enableRes);
|
|
461
|
+
if (state !== 'active' && state !== 'conditional') continue;
|
|
462
|
+
let port = L.port;
|
|
463
|
+
if (L.portOpt) { const pv = view(nixos(L.portOpt(m))); if (pv.state === 'known' && Number.isInteger(pv.value) && pv.value > 0) port = pv.value; }
|
|
464
|
+
const ingress = ingressFor(port);
|
|
465
|
+
const scope = L.scope(m);
|
|
466
|
+
inventory.push({ kind: 'declared-service', name: L.name, state, listening: 'yes', ports: [port], ingress, reachability: { internet: 'unknown', reason: 'external network topology is not part of the configuration' }, evidence: [evidenceOf(enableRes), evidenceOf(listenRes)] });
|
|
467
|
+
const st = state === 'conditional' || wild === 'conditional' ? 'conditional' : 'active';
|
|
468
|
+
const restricted = ingress === 'firewall-closed' || ingress === 'restricted-interface';
|
|
469
|
+
addFinding('listener-all-interfaces', { severity: restricted ? 'low' : ingress === 'open-in-firewall' || ingress === 'firewall-disabled' ? 'high' : 'medium', anchor: anchorOf(listenRes), scope, state: st, uncertain: !view(listenRes).definite, evidence: [evidenceOf(listenRes), evidenceOf(enableRes)], extra: { exposure: { enabled: true, listening: 'yes', ingress, reachable: 'unknown' } }, mitigations: restricted ? [{ kind: 'restrictive-ingress', ingress }] : [] });
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
401
473
|
// ── TLS material and enforcement on nginx virtual hosts ──
|
|
402
474
|
if (hasDefs('services.nginx.')) {
|
|
403
475
|
const nginxState = stateOf(nixos('services.nginx.enable'));
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Runtime correlation that knows which build it is talking about (E5-02).
|
|
2
|
+
//
|
|
3
|
+
// `correlateObservations` (observation-correlation.js) is pinned, byte for byte, by its own tests: it answers "was this flow seen in the window" and
|
|
4
|
+
// nothing about WHICH build was seen. This wrapper adds that, without changing it. A caller that names the static snapshot's identity gets back the
|
|
5
|
+
// same correlation, computed over only the observations that could be about that code, plus a statement per flow of whether the evidence is tied to
|
|
6
|
+
// the snapshot's build:
|
|
7
|
+
//
|
|
8
|
+
// corroborated every contributing observation matched the snapshot's identity (deployment-identity.js: matched)
|
|
9
|
+
// unattested at least one contributing observation carried no comparable identity: seen at runtime, but not shown to be this code
|
|
10
|
+
// none no contributing observation
|
|
11
|
+
//
|
|
12
|
+
// Observations from a DIFFERENT build are excluded and listed under `buildMismatchedObservationIds`: they are not evidence about this code at all.
|
|
13
|
+
// A caller that names no snapshot identity gets the original result unchanged.
|
|
14
|
+
import { execFileSync } from 'node:child_process';
|
|
15
|
+
import { correlateObservations } from './observation-correlation.js';
|
|
16
|
+
import { canCorroborate, linkObservationToSnapshot, normalizeIdentity, observationIdentity } from './deployment-identity.js';
|
|
17
|
+
|
|
18
|
+
const LAYER_RUNTIME_OBSERVED = 'runtime_observed';
|
|
19
|
+
|
|
20
|
+
/** The identity of the source snapshot at `root`: the git HEAD, whether the work tree is dirty, and anything the caller adds (image digest, build hash, environment). */
|
|
21
|
+
export function snapshotIdentityOf(root, extra = {}) {
|
|
22
|
+
let commit = null; let dirty = false;
|
|
23
|
+
try {
|
|
24
|
+
commit = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }).trim();
|
|
25
|
+
dirty = execFileSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10000 }).trim().length > 0;
|
|
26
|
+
} catch { commit = null; }
|
|
27
|
+
return { ...normalizeIdentity({ commit, dirty, ...extra }) };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {object} graph
|
|
32
|
+
* @param {object[]|null|undefined} observations as for correlateObservations
|
|
33
|
+
* @param {object} opts correlateObservations' options plus
|
|
34
|
+
* snapshotIdentity {commit, buildHash, imageDigest, sbomRef, environment, dirty} of the static snapshot. Without it the original result is returned unchanged.
|
|
35
|
+
* observationBuilds {[observationId]: {commit, buildHash, imageDigest, sbomRef}} identity the importer recorded for an observation
|
|
36
|
+
*/
|
|
37
|
+
export function correlateWithBuild(graph, observations, opts = {}) {
|
|
38
|
+
const snapshot = opts.snapshotIdentity && typeof opts.snapshotIdentity === 'object' ? normalizeIdentity(opts.snapshotIdentity) : null;
|
|
39
|
+
if (!snapshot || !Array.isArray(observations)) return correlateObservations(graph, observations, opts);
|
|
40
|
+
const builds = opts.observationBuilds && typeof opts.observationBuilds === 'object' ? opts.observationBuilds : {};
|
|
41
|
+
const links = new Map(); const kept = []; const mismatched = [];
|
|
42
|
+
for (const obs of observations) {
|
|
43
|
+
const id = obs && typeof obs.id === 'string' ? obs.id : null;
|
|
44
|
+
const claimed = id && builds[id] ? { ...observationIdentity(obs), ...normalizeIdentity({ ...builds[id], environment: obs.environment }) } : observationIdentity(obs);
|
|
45
|
+
const link = linkObservationToSnapshot(claimed, snapshot);
|
|
46
|
+
if (id) links.set(id, link);
|
|
47
|
+
if (link.state === 'mismatched') { if (id) mismatched.push(id); continue; }
|
|
48
|
+
kept.push(obs);
|
|
49
|
+
}
|
|
50
|
+
const base = correlateObservations(graph, kept, opts);
|
|
51
|
+
const byFlow = {};
|
|
52
|
+
for (const [fid, entry] of Object.entries(base.byFlow || {})) {
|
|
53
|
+
const ids = entry.observationIds || [];
|
|
54
|
+
const corroboration = entry.layer !== LAYER_RUNTIME_OBSERVED || !ids.length ? 'none' : ids.every((i) => canCorroborate(links.get(i))) ? 'corroborated' : 'unattested';
|
|
55
|
+
byFlow[fid] = { ...entry, corroboration };
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
...base, byFlow,
|
|
59
|
+
buildMismatchedObservationIds: [...new Set(mismatched)].sort(),
|
|
60
|
+
buildLink: { requested: true, snapshot: { commit: snapshot.commit, buildHash: snapshot.buildHash, imageDigest: snapshot.imageDigest, environment: snapshot.environment, dirty: snapshot.dirty } },
|
|
61
|
+
limitations: [...(base.limitations || []), 'Observations from a different build were excluded; observations with no comparable build identity are kept but marked unattested, never corroborated.'],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Tying runtime observations to the code that was deployed (E5-02).
|
|
2
|
+
//
|
|
3
|
+
// A runtime observation is evidence about ONE running build. A static finding is evidence about ONE source snapshot. The two are the same subject only
|
|
4
|
+
// when the build is known to have been made from that snapshot. Without that tie, "this route received traffic" says nothing about whether the code
|
|
5
|
+
// that received it contained the finding, and calling it corroboration would be a false claim.
|
|
6
|
+
//
|
|
7
|
+
// WHAT TIES THEM. Four identity fields, compared when both sides have them:
|
|
8
|
+
// commit the git commit (a full sha, or a prefix of at least 12 hex characters)
|
|
9
|
+
// buildHash a build or reproducibility hash
|
|
10
|
+
// imageDigest the container image digest (sha256:...)
|
|
11
|
+
// sbomRef an SBOM or artifact reference
|
|
12
|
+
// plus the environment the observation came from, which must equal the environment the snapshot's deployment record names when both are present.
|
|
13
|
+
//
|
|
14
|
+
// THE RULES (each is a test)
|
|
15
|
+
// mismatched a field both sides carry DIFFERS, or the environments differ. One disagreement is enough, however many fields agree.
|
|
16
|
+
// matched at least one STRONG field (commit, buildHash or imageDigest) is present on both sides and equal, and nothing disagrees
|
|
17
|
+
// unknown no strong field is present on both sides: the observation carries no identity, the snapshot carries none, or they carry different kinds
|
|
18
|
+
// A snapshot with uncommitted changes cannot be tied to a commit: a commit match is downgraded to `unknown` with that reason, because the code that was
|
|
19
|
+
// scanned is not the code the commit names.
|
|
20
|
+
// Only `matched` may be labelled corroborating. `unknown` is unattested evidence; `mismatched` is excluded and counted.
|
|
21
|
+
export const STRONG_FIELDS = Object.freeze(['commit', 'buildHash', 'imageDigest']);
|
|
22
|
+
export const IDENTITY_FIELDS = Object.freeze(['commit', 'buildHash', 'imageDigest', 'sbomRef']);
|
|
23
|
+
const ATTR = Object.freeze({ commit: 'deployment.commit', buildHash: 'deployment.build', imageDigest: 'deployment.image.digest', sbomRef: 'deployment.sbom.ref' });
|
|
24
|
+
export const IDENTITY_ATTRIBUTE_KEYS = Object.freeze(Object.values(ATTR));
|
|
25
|
+
|
|
26
|
+
const hex = (s) => (typeof s === 'string' && /^[0-9a-f]{12,64}$/i.test(s) ? s.toLowerCase() : null);
|
|
27
|
+
const digest = (s) => { const m = typeof s === 'string' ? /^(?:sha256:)?([0-9a-f]{64})$/i.exec(s) : null; return m ? `sha256:${m[1].toLowerCase()}` : null; };
|
|
28
|
+
const token = (s) => (typeof s === 'string' && /^[A-Za-z0-9._:-]{1,128}$/.test(s) ? s : null);
|
|
29
|
+
|
|
30
|
+
/** Normalise an identity-bearing object ({commit, buildHash, imageDigest, sbomRef, environment, dirty}) to comparable values; an unusable value becomes null. */
|
|
31
|
+
export function normalizeIdentity(raw) {
|
|
32
|
+
const r = raw && typeof raw === 'object' ? raw : {};
|
|
33
|
+
return { commit: hex(r.commit), buildHash: token(r.buildHash), imageDigest: digest(r.imageDigest), sbomRef: token(r.sbomRef), environment: token(r.environment), dirty: r.dirty === true };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The identity an observation carries, from its approved attributes and its environment. */
|
|
37
|
+
export function observationIdentity(obs) {
|
|
38
|
+
const a = (obs && obs.attributes) || {};
|
|
39
|
+
return normalizeIdentity({ commit: a[ATTR.commit], buildHash: a[ATTR.buildHash], imageDigest: a[ATTR.imageDigest], sbomRef: a[ATTR.sbomRef], environment: obs && obs.environment });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const sameCommit = (a, b) => { const n = Math.min(a.length, b.length); return n >= 12 && a.slice(0, n) === b.slice(0, n); };
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {object} observation a runtime observation (or an identity object)
|
|
46
|
+
* @param {object} snapshot the static snapshot's identity: {commit, buildHash, imageDigest, sbomRef, environment, dirty}
|
|
47
|
+
* @returns {{state:'matched'|'mismatched'|'unknown', compared:string[], agreed:string[], disagreed:string[], reasons:string[]}}
|
|
48
|
+
*/
|
|
49
|
+
export function linkObservationToSnapshot(observation, snapshot) {
|
|
50
|
+
const o = observation && observation.attributes !== undefined ? observationIdentity(observation) : normalizeIdentity(observation);
|
|
51
|
+
const s = normalizeIdentity(snapshot);
|
|
52
|
+
const compared = []; const agreed = []; const disagreed = []; const reasons = [];
|
|
53
|
+
for (const f of IDENTITY_FIELDS) {
|
|
54
|
+
if (o[f] == null || s[f] == null) continue;
|
|
55
|
+
compared.push(f);
|
|
56
|
+
const eq = f === 'commit' ? sameCommit(o[f], s[f]) : o[f] === s[f];
|
|
57
|
+
(eq ? agreed : disagreed).push(f);
|
|
58
|
+
}
|
|
59
|
+
if (o.environment != null && s.environment != null) {
|
|
60
|
+
compared.push('environment');
|
|
61
|
+
if (o.environment === s.environment) agreed.push('environment'); else disagreed.push('environment');
|
|
62
|
+
}
|
|
63
|
+
if (disagreed.length) return { state: 'mismatched', compared, agreed, disagreed, reasons: disagreed.map((f) => `${f} differs between the observation and the snapshot`) };
|
|
64
|
+
const strong = agreed.filter((f) => STRONG_FIELDS.includes(f));
|
|
65
|
+
if (!strong.length) {
|
|
66
|
+
if (o.commit == null && o.buildHash == null && o.imageDigest == null) reasons.push('the observation carries no commit, build hash or image digest');
|
|
67
|
+
else if (s.commit == null && s.buildHash == null && s.imageDigest == null) reasons.push('the snapshot carries no commit, build hash or image digest');
|
|
68
|
+
else reasons.push('the observation and the snapshot carry different kinds of identity, so none can be compared');
|
|
69
|
+
return { state: 'unknown', compared, agreed, disagreed, reasons };
|
|
70
|
+
}
|
|
71
|
+
if (s.dirty && strong.length === 1 && strong[0] === 'commit') return { state: 'unknown', compared, agreed, disagreed, reasons: ['the snapshot has uncommitted changes: the code that was analysed is not the code the commit names'] };
|
|
72
|
+
return { state: 'matched', compared, agreed, disagreed, reasons: [`${strong.join(', ')} agree and nothing disagrees`] };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** True only for a link that may be called corroboration. */
|
|
76
|
+
export const canCorroborate = (link) => !!link && link.state === 'matched';
|
package/src/mcp/tools.js
CHANGED
|
@@ -28,6 +28,7 @@ import { redactString, redactFinding } from './redact.js';
|
|
|
28
28
|
import { _remediationOf, normalizeFindings } from '../report/index.js';
|
|
29
29
|
// X-206: the one projection of the verification record, shared with the JSON report, the text report and the autopilot response.
|
|
30
30
|
import { verificationFields } from '../posture/verification/projection.js';
|
|
31
|
+
import { verifyPatch, additiveFields } from '../fix/verify-patch.js';
|
|
31
32
|
import { boundaryFields } from '../lineage/deployment/projection.js';
|
|
32
33
|
// Git-origin provenance (Finding Provenance M0/M1). Distinct from
|
|
33
34
|
// `finding.provenance` (AI-authorship) and from an SCA entry's `provenance`
|
|
@@ -879,25 +880,14 @@ export const apply_fix = {
|
|
|
879
880
|
}
|
|
880
881
|
// Inline re-verify — the load-bearing gate. Must pass to write.
|
|
881
882
|
let verdict;
|
|
883
|
+
let fixVerification = null;
|
|
882
884
|
try {
|
|
883
885
|
const _files = Object.fromEntries(Object.entries(confinedAbs).map(([rel, v]) => [rel, v.content]));
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
const { verifyFixWithTests } = await import('../posture/fix-verify-loop.js');
|
|
890
|
-
const t = await verifyFixWithTests({ scanRoot: ctx.sessionRoot, originalFindingStableId: f.stableId, files: _files });
|
|
891
|
-
verdict = { ok: t.ok, summary: t.summary, rescan: t.legs?.scan?.detail, lint: t.legs?.lint?.detail, tests: t.legs?.tests, testVerdict: t.verdict, verificationRecord: t.verificationRecord ?? null };
|
|
892
|
-
} else {
|
|
893
|
-
const verifyFixCore = await getVerifyFixCore();
|
|
894
|
-
verdict = await verifyFixCore({
|
|
895
|
-
scanRoot: ctx.sessionRoot,
|
|
896
|
-
originalFindingStableId: f.stableId,
|
|
897
|
-
files: _files,
|
|
898
|
-
fixMeta,
|
|
899
|
-
});
|
|
900
|
-
}
|
|
886
|
+
// One verifier for every route (E3-03). AGENTIC_SECURITY_FIX_RUN_TESTS used to swap in a second test runner here; verifyFix
|
|
887
|
+
// already carries the test leg, so the flag is now inert.
|
|
888
|
+
const central = await verifyPatch({ scanRoot: ctx.sessionRoot, finding: { id: f.id, stableId: f.stableId }, files: _files, fixMeta, route: 'mcp-apply-fix', mode: dry_run ? 'preview' : 'apply', verify: await getVerifyFixCore() });
|
|
889
|
+
verdict = central.verdict;
|
|
890
|
+
fixVerification = central.fixVerification;
|
|
901
891
|
} catch (e) {
|
|
902
892
|
return { _meta: META, applied: false, reason: `patch verification failed: ${e.message}` };
|
|
903
893
|
}
|
|
@@ -909,6 +899,7 @@ export const apply_fix = {
|
|
|
909
899
|
// X-201: the same version-1 verification record every surface emits (additive; null when none could be formed).
|
|
910
900
|
verificationRecord: verdict.verificationRecord ?? null,
|
|
911
901
|
...verificationFields(verdict.verificationRecord),
|
|
902
|
+
...(fixVerification ? additiveFields(fixVerification) : {}),
|
|
912
903
|
};
|
|
913
904
|
}
|
|
914
905
|
// FR-307/FR-1002/D-0024: this caller-supplied-patch branch writes via
|
|
@@ -929,7 +920,7 @@ export const apply_fix = {
|
|
|
929
920
|
}
|
|
930
921
|
const materialClassification = classifyFixMaterialRisk(filesForMaterialClassification);
|
|
931
922
|
if (dry_run) {
|
|
932
|
-
return { _meta: META, applied: false, dryRun: true, verified: true, files: Object.keys(confinedAbs), summary: verdict.summary, verificationRecord: verdict.verificationRecord ?? null, ...verificationFields(verdict.verificationRecord), materialClassification };
|
|
923
|
+
return { _meta: META, applied: false, dryRun: true, verified: true, files: Object.keys(confinedAbs), summary: verdict.summary, verificationRecord: verdict.verificationRecord ?? null, ...verificationFields(verdict.verificationRecord), materialClassification, ...(fixVerification ? additiveFields(fixVerification) : {}) };
|
|
933
924
|
}
|
|
934
925
|
const approvalRefusal = _highImpactApprovalRefusal(ctx, materialClassification, fixMeta);
|
|
935
926
|
if (approvalRefusal) return approvalRefusal;
|
|
@@ -960,7 +951,7 @@ export const apply_fix = {
|
|
|
960
951
|
}
|
|
961
952
|
let acceptance = null;
|
|
962
953
|
try { acceptance = fixAcceptanceRate(ctx.sessionRoot); } catch { /* best-effort */ }
|
|
963
|
-
return { _meta: META, applied: true, verified: true, patched: written, integrity: status, verify: { summary: verdict.summary, verificationRecord: verdict.verificationRecord ?? null, ...verificationFields(verdict.verificationRecord) }, acceptance, materialClassification };
|
|
954
|
+
return { _meta: META, applied: true, verified: true, patched: written, integrity: status, verify: { summary: verdict.summary, verificationRecord: verdict.verificationRecord ?? null, ...verificationFields(verdict.verificationRecord) }, acceptance, materialClassification, ...(fixVerification ? additiveFields(fixVerification) : {}) };
|
|
964
955
|
}
|
|
965
956
|
|
|
966
957
|
if (typeof f.fix?.replacement !== 'string') {
|
|
@@ -1118,21 +1109,8 @@ export const verify_fix = {
|
|
|
1118
1109
|
// Best-effort: a missing/unsigned/tampered scan just means no PoC is
|
|
1119
1110
|
// available to re-check, not a verify_fix failure — the rescan/lint/
|
|
1120
1111
|
// tests legs below are independent of this and still apply.
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
const { scan: lastScan } = _readLastScanVerified(ctx.sessionRoot, { allowUnsigned: true });
|
|
1124
|
-
const orig = lastScan && (lastScan.findings || []).find(f => f.stableId === stable_id);
|
|
1125
|
-
if (orig && orig.poc && orig.poc.code) poc = { ...orig.poc, finding: orig };
|
|
1126
|
-
} catch { /* best-effort lookup; poc stays null */ }
|
|
1127
|
-
|
|
1128
|
-
const verifyFixCore = await getVerifyFixCore();
|
|
1129
|
-
const r = await verifyFixCore({
|
|
1130
|
-
scanRoot: ctx.sessionRoot,
|
|
1131
|
-
originalFindingStableId: stable_id,
|
|
1132
|
-
files: confined,
|
|
1133
|
-
poc,
|
|
1134
|
-
fixMeta,
|
|
1135
|
-
});
|
|
1112
|
+
const central = await verifyPatch({ scanRoot: ctx.sessionRoot, finding: { stableId: stable_id }, files: confined, fixMeta, route: 'mcp-verify-fix', mode: 'verify', verify: await getVerifyFixCore() });
|
|
1113
|
+
const r = central.verdict;
|
|
1136
1114
|
return {
|
|
1137
1115
|
_meta: META,
|
|
1138
1116
|
ok: r.ok,
|
|
@@ -1154,6 +1132,7 @@ export const verify_fix = {
|
|
|
1154
1132
|
// `not-run` when no exploit oracle executed against the patch, never a bare pass/fail.
|
|
1155
1133
|
verificationRecord: r.verificationRecord ?? null,
|
|
1156
1134
|
...verificationFields(r.verificationRecord),
|
|
1135
|
+
...additiveFields(central.fixVerification),
|
|
1157
1136
|
};
|
|
1158
1137
|
} catch (e) {
|
|
1159
1138
|
return { _meta: META, ok: false, reason: `verify_fix failed: ${e.message}` };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Canonical scan output (E6-06).
|
|
2
|
+
//
|
|
3
|
+
// A scan result mixes two kinds of fact. Some are about the CODE: which findings exist, where, with what evidence. Those must be identical on every
|
|
4
|
+
// run over the same input, whatever order the files were read in, however many scans ran at once, and whenever it happened. The rest are about the
|
|
5
|
+
// RUN: when it started, how long each stage took, which stage finished first. Those differ every time and are not a defect.
|
|
6
|
+
//
|
|
7
|
+
// This module splits them:
|
|
8
|
+
// canonical findings (and the other finding-bearing lists) with volatile fields removed, numbers rounded to a fixed precision, object keys sorted,
|
|
9
|
+
// lists in a total order (file, line, vuln, stableId, id, then the serialized record as the last tiebreak). Hashed into `digest`.
|
|
10
|
+
// run timestamps, durations, telemetry, scan id; kept, separate, and never part of the digest
|
|
11
|
+
// provenance what produced the canonical part: scanner version, rule-pack hash, and each source of NONDETERMINISM that was active (a network feed,
|
|
12
|
+
// a model), named so a reader knows which results could legitimately differ
|
|
13
|
+
//
|
|
14
|
+
// The digest therefore answers one question: did two runs find the same things? A difference in `run` never changes it.
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
import { computeRulePackHash } from '../posture/deterministic.js';
|
|
17
|
+
|
|
18
|
+
const LISTS = Object.freeze(['findings', 'secrets', 'logicVulns', 'supplyChain']);
|
|
19
|
+
// fields inside a finding that describe the run, not the code
|
|
20
|
+
const VOLATILE_FINDING_KEYS = new Set(['detectedAt', 'scannedAt', 'ts', 'timestamp', 'elapsedMs', 'durationMs', 'scanId']);
|
|
21
|
+
const PRECISION = 6;
|
|
22
|
+
|
|
23
|
+
const round = (n) => (Number.isInteger(n) ? n : Math.round(n * 10 ** PRECISION) / 10 ** PRECISION);
|
|
24
|
+
function clean(v) {
|
|
25
|
+
if (Array.isArray(v)) return v.map(clean);
|
|
26
|
+
if (v && typeof v === 'object') {
|
|
27
|
+
const o = {};
|
|
28
|
+
for (const k of Object.keys(v).sort()) { if (VOLATILE_FINDING_KEYS.has(k) || v[k] === undefined) continue; o[k] = clean(v[k]); }
|
|
29
|
+
return o;
|
|
30
|
+
}
|
|
31
|
+
return typeof v === 'number' && Number.isFinite(v) ? round(v) : v;
|
|
32
|
+
}
|
|
33
|
+
const s = (x) => (x === undefined || x === null ? '' : String(x));
|
|
34
|
+
function order(a, b) {
|
|
35
|
+
return s(a.file).localeCompare(s(b.file), 'en') || (Number(a.line) || 0) - (Number(b.line) || 0) || s(a.vuln || a.title).localeCompare(s(b.vuln || b.title), 'en')
|
|
36
|
+
|| s(a.stableId).localeCompare(s(b.stableId), 'en') || s(a.id).localeCompare(s(b.id), 'en') || JSON.stringify(a).localeCompare(JSON.stringify(b), 'en');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Sources of nondeterminism that are active for this process, named. */
|
|
40
|
+
export function nondeterminismSources(env = process.env) {
|
|
41
|
+
const out = [];
|
|
42
|
+
if (env.AGENTIC_SECURITY_LLM_ENDPOINT && env.AGENTIC_SECURITY_LLM_VALIDATE !== '0') out.push({ source: 'llm-validator', reason: 'a model endpoint is configured and the validator is on: its verdicts can differ between runs', model: env.AGENTIC_SECURITY_LLM_MODEL || null });
|
|
43
|
+
if (env.AGENTIC_SECURITY_DETERMINISTIC !== '1' && env.AGENTIC_SECURITY_OFFLINE !== '1') out.push({ source: 'network-feeds', reason: 'vulnerability and registry feeds are fetched and cached; their content changes over time' });
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {object} scan a scan result
|
|
49
|
+
* @param {{env?:object, scannerVersion?:string}} opts
|
|
50
|
+
* @returns {{canonical:object, digest:string, run:object, provenance:object}}
|
|
51
|
+
*/
|
|
52
|
+
export function canonicalizeScan(scan, opts = {}) {
|
|
53
|
+
const canonical = {};
|
|
54
|
+
for (const k of LISTS) if (Array.isArray(scan && scan[k])) canonical[k] = scan[k].map(clean).sort(order);
|
|
55
|
+
const digest = createHash('sha256').update(JSON.stringify(canonical)).digest('hex');
|
|
56
|
+
const meta = (scan && (scan._scanMeta || scan.meta)) || {};
|
|
57
|
+
const run = {
|
|
58
|
+
scanId: meta.scanId ?? null, startedAt: meta.startedAt ?? null, durationMs: meta.durationMs ?? null,
|
|
59
|
+
telemetry: scan && scan.telemetry ? scan.telemetry : null,
|
|
60
|
+
fileTimings: Array.isArray(meta.fileTimings) ? meta.fileTimings : null,
|
|
61
|
+
};
|
|
62
|
+
const provenance = { scannerVersion: opts.scannerVersion ?? null, rulePackHash: computeRulePackHash(), nondeterminism: nondeterminismSources(opts.env), canonicalPrecision: PRECISION };
|
|
63
|
+
return { canonical, digest, run, provenance };
|
|
64
|
+
}
|