@clear-capabilities/agentic-security-scanner 0.154.0 → 0.156.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 +29 -0
- package/bin/agentic-security.js +8 -0
- package/dist/1919.index.js +445 -0
- package/dist/2862.index.js +2 -2
- package/dist/8551.index.js +47 -19
- package/dist/8798.index.js +4 -2
- package/dist/agentic-security.mjs +4 -4
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +6 -4
- package/src/engine.js +2 -2
- package/src/language/assurance.js +2 -2
- package/src/language/haskell-guards.js +12 -5
- package/src/language/haskell-ir.js +8 -1
- package/src/language/haskell-models.js +8 -0
- package/src/language/haskell-security-rules.js +23 -2
- package/src/language/haskell-supply.js +6 -6
- package/src/language/haskell-web.js +3 -0
- package/src/language/nix-secrets.js +2 -0
- package/src/language/nixos-hardening.js +32 -1
- package/src/language/resolved-pass.js +45 -17
- package/src/language/support-registry.js +70 -1
- package/src/language/trusted-inputs.js +38 -0
- package/src/language/witness.js +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,35 @@
|
|
|
9
9
|
> make the history less accurate, not more.
|
|
10
10
|
|
|
11
11
|
|
|
12
|
+
## 0.156.0 - the three below-target rows fixed, measured on a fresh unseen set
|
|
13
|
+
|
|
14
|
+
0.155.0 reported Haskell SAST recall (79.2%), Haskell taint precision (77.8%) and Nix config-sast precision (86.1%) below target on shapes the templates did not contain, while the frozen holdout said 100%. Each gap was read against the code and fixed in the engine, not in the scoring.
|
|
15
|
+
|
|
16
|
+
- **Haskell, structural rules.** A function named for a PIN or password is a security value for the randomness rule (matched as a whole word, so `spinWheel` is not); `replicateM`/`replicateM_` sized by parsed caller text is an unbounded allocation; `xs !! n` on a caller-supplied list is a partial function; a log argument labelled `"token=" ++ x` or `"password: " ++ x` is a credential, unless it is redacted.
|
|
17
|
+
- **Haskell, route authentication.** A wrapper guard (`guarded $ do ...`) now dominates the handler it encloses: program order is source order, not control-flow node order. A guard that runs AFTER the write is still reported.
|
|
18
|
+
- **Haskell, taint.** `lookup key table` returns an element of the table, so only the table flows to the result (the key stays visible to rules as `hs.selector`, so `fromJust (lookup key t)` is still a partial-function finding); `length`, `null`, `ord` and similar return numbers and carry no caller text; a host allow-list (`uriRegName a `elem` [...]`) over a URL parsed from the checked value is a URL guard.
|
|
19
|
+
- **Nix.** `security.sudo.extraRules` with `NOPASSWD` for `ALL` is a finding (one named command is not); a TLS key produced by `writeText`/`toFile` is in the store like a path literal; wpa_supplicant's `ext:NAME` is a reference to a runtime variable, not a credential.
|
|
20
|
+
- **The measurement.** The unseen-v1 shapes were used to make these fixes, so they are now the `shape-dev` regression set. A fresh unseen-v2 set (3 vulnerable and 3 safe NEW single-flaw shapes per family, 2 nouns each, written after the fixes) was measured ONCE with the fixed engine: Haskell SAST P 91.7% / R 91.7%, Haskell taint P 93.3% / R 93.3%, Nix config-sast P 100% / R 97.0%; all three meet the targets (precision 90%, recall 85%). The frozen holdout is unchanged at 100% (re-measured after the engine change, which counts as repeated holdout use; nothing was tuned against it). Intervals on the unseen numbers are now over shapes, not near-identical cases.
|
|
21
|
+
- **What the fresh set still misses (not fixed, because fixing them would consume it).** Haskell: `strictRequestBody` read through an explicit import list, `fromJust (lookup k t)` (measured before the key-visibility fix above), `when (isNothing h) (... >> finish)` and `X-Api-Key` header checks reported as missing authentication, `getWith opts url` (the URL is the second argument), and duplicate findings for `readCreateProcess (shell ...)`. Nix: a firewall opened by a full-range `allowedTCPPortRanges`. The causes were not investigated (that would consume the set); these are the next known gaps. One engine change, the `fromJust (lookup ...)` regression fix, was made after the unseen measurement because the `lookup` change itself had caused it; the stored numbers were not re-measured, so they are conservative, and the set should be treated as consumed for anything after this release (an unseen-v3 is needed for the next round).
|
|
22
|
+
- **Author errors in the retired set, named.** A cookie called `usersses` (nothing in the code says it is a session), an HTML shape that prints to stdout, vulnerable Nix shapes with a second real flaw, and a path shape with two sinks. They stay in `shape-dev` with a note; they were not "fixed" in the engine.
|
|
23
|
+
|
|
24
|
+
- **A flaky probe, fixed.** The Haskell witness runner's `runghc --version` probe gave up after 10 s, which a cold CI runner exceeded once: the toolchain then read as absent in one call and present in the next, failing a test that asked twice. The probe now allows 60 s, says so when it times out, and the test decides availability once.
|
|
25
|
+
|
|
26
|
+
Partial release: the loop controller still lists NIX-011, NIX-012 and REL-001 as not verified. NIX-011 and NIX-012 passed on hosted CI (the NIX-012 suite inside a real NixOS guest on x86_64; no aarch64 guest was run), which is CI evidence, not controller-signed evidence. REL-001 needs those to be controller-verified first.
|
|
27
|
+
|
|
28
|
+
## 0.155.0 - premortem remediation (trust boundary, unseen-shape measurement), NixOS VM check
|
|
29
|
+
|
|
30
|
+
An adversarial premortem of 0.154.0 found the following; each is fixed here and covered by `test/language/language-trust-boundary.test.js`.
|
|
31
|
+
|
|
32
|
+
- **Trust boundary.** Advisory snapshots are read only from the operator (environment variable or `$XDG_CONFIG_HOME/agentic-security/`). A snapshot inside the scanned project is ignored and the scan says so, so a repository can no longer ship an empty feed to look clean. Nix export freshness is derived by the scanner from `flake.lock`, project-supplied exports and plans are disclosed, a refused export contributes nothing, and a Cabal plan that contradicts the freeze file is flagged.
|
|
33
|
+
- **Unseen-shape split.** The holdout shares its code shapes with development, so it measures robustness to renaming only. A new `unseen` split (300 author-labelled cases, 3 new vulnerable and 3 new safe shapes per family) is measured once and never tuned against. Its result is stored beside every holdout row with 95% Clopper-Pearson intervals and strict precision. It is **below target** for Haskell SAST (R 79.2%), Haskell taint (P 77.8%) and Nix config-sast (P 86.1%); those are engine gaps, not fixed here. The `auth` row is now promoted from its own holdout evidence.
|
|
34
|
+
- **Completion honesty.** A release check (`completion-declared`) fails when `docs/completion-status.json` lists unverified requirements and the changelog has no paragraph naming them. Chrome-dependent export tests are a separate informational job rather than a silent skip.
|
|
35
|
+
- **Nix package fix (found by the first hosted NixOS guest run).** The packaged `agentic-security-mcp` and `agentic-security-lsp` wrappers ran `bin/*.js`, which import `src/` and therefore npm dependencies the Nix package does not ship, so both would have crashed for a Nix user. They now run from the self-contained bundle, and the CLI gained an `lsp` subcommand (the same server the editor plugins start). The flake's package source is now exactly the files it installs, so an offline rebuild elsewhere reproduces the same derivation.
|
|
36
|
+
- **Destructive-command guard.** `hooks/pre-bash-guard.js` judged `rm` by text, so it refused any absolute path (and `docker run --rm -v /abs/...`) while missing nothing it should. Deletion (`rm`, `rmdir`, `unlink`, `find -delete`) is now path-aware: targets are parsed and resolved (`~`, `$HOME`, relative paths, `cd`, symlinks) and allowed only strictly inside an allowed root (default `~/code`, set with `allowedRoots` in `.agentic-security/destructive-guard.json`). Anything outside, anything unresolvable (`$VAR`, command substitution, `xargs`) and the root itself is refused. It cannot see deletion done inside another program, so it guards against mistakes and is not a sandbox. Pinned by `test/pre-bash-guard.test.js`.
|
|
37
|
+
- **Not done.** Peak scan memory is measured under a stated heap cap and was **not reduced**: a lazy-token experiment retained less but changed no measurement, so it was reverted. `flake.nix` is now locked (`flake.lock`), built and checked on aarch64-linux in a `nixos/nix` container (Nix 2.35.2) and on x86_64-linux on a hosted runner; aarch64-darwin is declared but unbuilt, and x86_64-darwin is dropped (current nixpkgs no longer supports it).
|
|
38
|
+
|
|
39
|
+
Partial release: the loop controller lists NIX-011, NIX-012 and REL-001 as not verified. NIX-011 and NIX-012 passed on hosted CI in this release's pull request (the NIX-011 suite on Nix 2.24.10; the NIX-012 suite inside a real NixOS guest booted by the flake's `nixos-host` check on an x86_64 runner with KVM), but that is CI evidence, not controller-signed evidence, because the controller runs where `nix` and `/etc/NIXOS` are absent. The aarch64 NixOS guest was not run: the arm runner has no KVM and a software-emulated guest does not boot in any usable time. REL-001 is the controller's own final acceptance and needs those two to be controller-verified first.
|
|
40
|
+
|
|
12
41
|
## 0.154.0 - Haskell and Nix/NixOS support, with every limit stated
|
|
13
42
|
|
|
14
43
|
Haskell and Nix/NixOS are now scanned by the same commands as every other language, with no compiler, no `nix` binary and no
|
package/bin/agentic-security.js
CHANGED
|
@@ -137,6 +137,7 @@ Commands:
|
|
|
137
137
|
tickets sync --provider <p> Two-way sync findings ↔ GitHub Issues / Linear / Jira
|
|
138
138
|
digest --slack <webhook> Vibecoder: send daily digest to Slack
|
|
139
139
|
mcp Start the MCP stdio server (scan_diff, query_taint, explain_finding, apply_fix)
|
|
140
|
+
lsp Start the Language Server Protocol server on stdio (what the editor plugins run)
|
|
140
141
|
validator-cache stats|gc Inspect / prune .agentic-security/llm-cache/ (use --older-than <days> --dry-run)
|
|
141
142
|
verify [--finding <id>] Re-run the verifier loop on last-scan findings (use --live --target <url> to execute PoCs)
|
|
142
143
|
ask "<question>" [target] Bounded local-model Q&A with read-only tool access (requires AGENTIC_SECURITY_LLM_PRESET=ollama)
|
|
@@ -7056,6 +7057,13 @@ async function main() {
|
|
|
7056
7057
|
runStdio({ sessionRoot: path.resolve(root) });
|
|
7057
7058
|
return;
|
|
7058
7059
|
}
|
|
7060
|
+
case 'lsp': {
|
|
7061
|
+
// The same server as bin/agentic-security-lsp.js, reachable from the self-contained bundle: a package that ships only the bundle
|
|
7062
|
+
// (the Nix package does) has no node_modules for the src/ entry point to import from.
|
|
7063
|
+
const { startLspServer } = await import('../src/lsp/server.js');
|
|
7064
|
+
startLspServer();
|
|
7065
|
+
return;
|
|
7066
|
+
}
|
|
7059
7067
|
case 'explore': process.exit(await cmdExplore(args));
|
|
7060
7068
|
case 'dataflow': {
|
|
7061
7069
|
const sub = args._[1];
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
export const id = 1919;
|
|
2
|
+
export const ids = [1919];
|
|
3
|
+
export const modules = {
|
|
4
|
+
|
|
5
|
+
/***/ 21919:
|
|
6
|
+
/***/ ((__webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
7
|
+
|
|
8
|
+
__webpack_require__.a(__webpack_module__, async (__webpack_handle_async_dependencies__, __webpack_async_result__) => { try {
|
|
9
|
+
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
10
|
+
/* harmony export */ startLspServer: () => (/* binding */ startLspServer)
|
|
11
|
+
/* harmony export */ });
|
|
12
|
+
/* unused harmony export _internals */
|
|
13
|
+
/* harmony import */ var _language_discovery_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(30951);
|
|
14
|
+
/* harmony import */ var _language_context_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(15911);
|
|
15
|
+
/* harmony import */ var node_fs__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(73024);
|
|
16
|
+
/* harmony import */ var node_path__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(76760);
|
|
17
|
+
/* harmony import */ var node_readline__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(80481);
|
|
18
|
+
/* harmony import */ var _runScan_js__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(45950);
|
|
19
|
+
/* harmony import */ var _posture_custom_rules_js__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(9049);
|
|
20
|
+
/* harmony import */ var _posture_state_dir_js__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(31174);
|
|
21
|
+
/* harmony import */ var _mcp_redact_js__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(83468);
|
|
22
|
+
/* harmony import */ var _report_index_js__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(75917);
|
|
23
|
+
var __webpack_async_dependencies__ = __webpack_handle_async_dependencies__([_runScan_js__WEBPACK_IMPORTED_MODULE_5__, _report_index_js__WEBPACK_IMPORTED_MODULE_8__]);
|
|
24
|
+
([_runScan_js__WEBPACK_IMPORTED_MODULE_5__, _report_index_js__WEBPACK_IMPORTED_MODULE_8__] = __webpack_async_dependencies__.then ? (await __webpack_async_dependencies__)() : __webpack_async_dependencies__);
|
|
25
|
+
// Minimal LSP server for agentic-security.
|
|
26
|
+
//
|
|
27
|
+
// Speaks the Language Server Protocol over stdio. On every textDocument/
|
|
28
|
+
// didSave (and didOpen), the server runs runScan on the file and emits
|
|
29
|
+
// textDocument/publishDiagnostics with the resulting findings mapped to
|
|
30
|
+
// LSP Diagnostic objects.
|
|
31
|
+
//
|
|
32
|
+
// This is a STARTER implementation — feature-complete enough that JetBrains
|
|
33
|
+
// (via LSP4IJ) and Neovim (via built-in LSP) can both attach and see
|
|
34
|
+
// findings inline. The full feature set (code actions for /fix, inline
|
|
35
|
+
// remediation hover, exploitability tooltip) is future work.
|
|
36
|
+
//
|
|
37
|
+
// Wire-format: vscode-jsonrpc framing (Content-Length headers). Stateless
|
|
38
|
+
// per file — no incremental analysis yet.
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
const PROTOCOL_VERSION = '3.17';
|
|
52
|
+
const SERVER_NAME = 'agentic-security-lsp';
|
|
53
|
+
const SERVER_VERSION = '0.1.0';
|
|
54
|
+
|
|
55
|
+
let _rootUri = null;
|
|
56
|
+
let _rootDir = process.cwd();
|
|
57
|
+
let _stdoutMutex = Promise.resolve();
|
|
58
|
+
const _diagnosticsByUri = new Map();
|
|
59
|
+
|
|
60
|
+
function uriToPath(uri) {
|
|
61
|
+
if (!uri) return null;
|
|
62
|
+
if (uri.startsWith('file://')) return decodeURIComponent(uri.slice(7));
|
|
63
|
+
return uri;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function pathToUri(p) {
|
|
67
|
+
if (!p) return null;
|
|
68
|
+
if (p.startsWith('file://')) return p;
|
|
69
|
+
return 'file://' + encodeURI(node_path__WEBPACK_IMPORTED_MODULE_3__.resolve(p));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function sevToLsp(sev) {
|
|
73
|
+
switch ((sev || '').toLowerCase()) {
|
|
74
|
+
case 'critical': return 1; // Error
|
|
75
|
+
case 'high': return 1; // Error
|
|
76
|
+
case 'medium': return 2; // Warning
|
|
77
|
+
case 'low': return 3; // Information
|
|
78
|
+
default: return 4; // Hint
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The most precise location a finding carries. Haskell and Nix findings keep their ORIGINAL span (0-based columns); a
|
|
83
|
+
// finding with only a line is reported across that line, as before.
|
|
84
|
+
function _rangeOf(f) {
|
|
85
|
+
const o = f && f.originalLocation;
|
|
86
|
+
if (o && Number.isInteger(o.startLine) && Number.isInteger(o.startColumn)) {
|
|
87
|
+
return { start: { line: Math.max(0, o.startLine - 1), character: o.startColumn }, end: { line: Math.max(0, (Number.isInteger(o.endLine) ? o.endLine : o.startLine) - 1), character: Number.isInteger(o.endColumn) ? o.endColumn : o.startColumn + 1 } };
|
|
88
|
+
}
|
|
89
|
+
if (o && Number.isInteger(o.line) && Number.isInteger(o.column)) {
|
|
90
|
+
const line = Math.max(0, o.line - 1);
|
|
91
|
+
return { start: { line, character: o.column }, end: { line, character: 200 } };
|
|
92
|
+
}
|
|
93
|
+
const line = Math.max(0, ((f && f.line) || 1) - 1);
|
|
94
|
+
return { start: { line, character: 0 }, end: { line, character: 200 } };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function findingToDiagnostic(f) {
|
|
98
|
+
const line = Math.max(0, (f.line || 1) - 1);
|
|
99
|
+
// Stage 6 correctness audit: this read f.remediation directly, but raw
|
|
100
|
+
// scan.findings entries (what this consumes, pre-normalizeFindings) come
|
|
101
|
+
// from two conventions — most posture/*.js and newer sast/*.js modules
|
|
102
|
+
// set `remediation`, while ~127 of engine.js's own detectors set a `fix`
|
|
103
|
+
// STRING field instead. _remediationOf carries the same precedence
|
|
104
|
+
// report/index.js already established for this exact split (CMP-3).
|
|
105
|
+
const remediation = (0,_report_index_js__WEBPACK_IMPORTED_MODULE_8__/* ._remediationOf */ .uV)(f);
|
|
106
|
+
return {
|
|
107
|
+
range: _rangeOf(f),
|
|
108
|
+
severity: sevToLsp(f.severity),
|
|
109
|
+
source: 'agentic-security',
|
|
110
|
+
code: f.cwe || f.family || 'finding',
|
|
111
|
+
// carried back by the client in codeAction requests, so a fix is offered for exactly this finding
|
|
112
|
+
data: { id: f.id || null, stableId: f.stableId || null, rule: f.rule || null, family: f.family || null, language: f.language || (0,_language_context_js__WEBPACK_IMPORTED_MODULE_1__.languageOfFinding)(f) || null },
|
|
113
|
+
message: `${f.vuln || 'Security finding'}${remediation ? '\n\n' + remediation : ''}`.slice(0, 2000),
|
|
114
|
+
tags: [],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function send(message) {
|
|
119
|
+
const json = JSON.stringify({ jsonrpc: '2.0', ...message });
|
|
120
|
+
_stdoutMutex = _stdoutMutex.then(() => new Promise(resolve => {
|
|
121
|
+
process.stdout.write(`Content-Length: ${Buffer.byteLength(json, 'utf8')}\r\n\r\n${json}`, resolve);
|
|
122
|
+
}));
|
|
123
|
+
return _stdoutMutex;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function publishDiagnostics(uri, findings) {
|
|
127
|
+
await send({
|
|
128
|
+
method: 'textDocument/publishDiagnostics',
|
|
129
|
+
params: { uri, diagnostics: findings.map(findingToDiagnostic) },
|
|
130
|
+
});
|
|
131
|
+
_diagnosticsByUri.set(uri, findings);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Manifest / schema files that downstream passes (SCA, cross-language) read.
|
|
135
|
+
// We walk the project tree once per LSP session and cache these so the
|
|
136
|
+
// per-save scan has them.
|
|
137
|
+
const DEP_BASE_NAMES = new Set([
|
|
138
|
+
'package.json', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml',
|
|
139
|
+
'requirements.txt', 'pyproject.toml', 'poetry.lock', 'Pipfile.lock',
|
|
140
|
+
'composer.json', 'composer.lock', 'Gemfile', 'Gemfile.lock',
|
|
141
|
+
'go.mod', 'Cargo.toml', 'Cargo.lock',
|
|
142
|
+
'pom.xml', 'build.gradle', 'build.gradle.kts',
|
|
143
|
+
]);
|
|
144
|
+
const DEP_EXT_RE = /\.(?:proto|graphql|gql|tf|cabal)$/i;
|
|
145
|
+
// Haskell and Nix project manifests and lock files (cabal.project*, stack.yaml*, package.yaml, flake.lock, ...) are
|
|
146
|
+
// recognised by the shared language discovery module, never by a second list here.
|
|
147
|
+
const _isLanguageDep = (base) => (0,_language_discovery_js__WEBPACK_IMPORTED_MODULE_0__/* .isLanguageManifest */ .mD)(base);
|
|
148
|
+
const DEP_NAME_RE = /(?:openapi|swagger)\.(?:ya?ml|json)$/i;
|
|
149
|
+
|
|
150
|
+
let _depCache = { rootDir: null, depFileContents: {} };
|
|
151
|
+
|
|
152
|
+
function _loadDepFileContents(rootDir) {
|
|
153
|
+
if (_depCache.rootDir === rootDir) return _depCache.depFileContents;
|
|
154
|
+
const out = {};
|
|
155
|
+
const skipDirs = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'target', 'vendor', '.bench-cache']);
|
|
156
|
+
function walk(dir) {
|
|
157
|
+
let entries;
|
|
158
|
+
try { entries = node_fs__WEBPACK_IMPORTED_MODULE_2__.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
159
|
+
for (const e of entries) {
|
|
160
|
+
if (skipDirs.has(e.name)) continue;
|
|
161
|
+
const full = node_path__WEBPACK_IMPORTED_MODULE_3__.join(dir, e.name);
|
|
162
|
+
if (e.isDirectory()) { walk(full); continue; }
|
|
163
|
+
if (!e.isFile()) continue;
|
|
164
|
+
const base = e.name;
|
|
165
|
+
if (DEP_BASE_NAMES.has(base) || DEP_EXT_RE.test(base) || DEP_NAME_RE.test(base) || _isLanguageDep(base)) {
|
|
166
|
+
let stat;
|
|
167
|
+
try { stat = node_fs__WEBPACK_IMPORTED_MODULE_2__.statSync(full); } catch { continue; }
|
|
168
|
+
if (stat.size > 500_000) continue;
|
|
169
|
+
try { out[node_path__WEBPACK_IMPORTED_MODULE_3__.relative(rootDir, full)] = node_fs__WEBPACK_IMPORTED_MODULE_2__.readFileSync(full, 'utf8'); }
|
|
170
|
+
catch { /* skip unreadable */ }
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
walk(rootDir);
|
|
175
|
+
_depCache = { rootDir, depFileContents: out };
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function scanFile(uri) {
|
|
180
|
+
const filePath = uriToPath(uri);
|
|
181
|
+
if (!filePath || !node_fs__WEBPACK_IMPORTED_MODULE_2__.existsSync(filePath)) return;
|
|
182
|
+
// Incremental scan (premortem 2R4.5 / 2R-10): hand runScan a single-file
|
|
183
|
+
// fileContents map for the saved code, AND a cached set of dep-manifest /
|
|
184
|
+
// schema files so SCA + cross-language passes have their inputs. Without
|
|
185
|
+
// depFileContents, the LSP path would silently drop CVE / OpenAPI / proto
|
|
186
|
+
// findings on the saved file.
|
|
187
|
+
try {
|
|
188
|
+
const rel = node_path__WEBPACK_IMPORTED_MODULE_3__.relative(_rootDir, filePath);
|
|
189
|
+
const content = node_fs__WEBPACK_IMPORTED_MODULE_2__.readFileSync(filePath, 'utf8');
|
|
190
|
+
let fileContents = { [rel]: content };
|
|
191
|
+
let depFileContents = _loadDepFileContents(_rootDir);
|
|
192
|
+
// Haskell/Nix: scan the saved file together with the modules it imports and the ones that import it (bounded).
|
|
193
|
+
const lc = (0,_language_context_js__WEBPACK_IMPORTED_MODULE_1__.withLanguageContext)(_rootDir, fileContents, depFileContents);
|
|
194
|
+
fileContents = lc.fileContents; depFileContents = lc.depFileContents;
|
|
195
|
+
// Premortem 4R-12 + 4R-15: reset the per-process custom-rules budget at
|
|
196
|
+
// the start of each LSP scan. Each save is a logical scan session; without
|
|
197
|
+
// the reset, a long-lived LSP server would accumulate budget across saves
|
|
198
|
+
// and eventually start skipping custom rules.
|
|
199
|
+
(0,_posture_custom_rules_js__WEBPACK_IMPORTED_MODULE_6__/* .resetCustomRulesBudget */ .zO)(_rootDir);
|
|
200
|
+
// PRD R1 (docs/DETECTION_GAP_REMEDIATION_PRD.md): deep mode is
|
|
201
|
+
// default-on for the interactive CLI scan but was never requested here,
|
|
202
|
+
// so every on-save diagnostic pass was regex/AST-only — blind to any bug
|
|
203
|
+
// whose source and sink are connected only through a call. Scoped to
|
|
204
|
+
// exactly the saved file (fileContents has one entry), so this does not
|
|
205
|
+
// turn every keystroke's save into a full-project deep scan.
|
|
206
|
+
// withStateWritesDisabled, for the same reason mcp/tools.js's scan_diff
|
|
207
|
+
// wraps its own partial-set scan (FR-704). This is a DIAGNOSTIC surface: it
|
|
208
|
+
// runs on every file save, against the user's real project root, with a
|
|
209
|
+
// fileContents map holding exactly one file. Without the wrapper,
|
|
210
|
+
// runFullScan's state writers fire on every keystroke-save — dpia.md,
|
|
211
|
+
// ropa.md, privacy-framework.json, threat-model.json and the rest, written
|
|
212
|
+
// into the user's tree by an editor plugin they never asked to mutate
|
|
213
|
+
// anything.
|
|
214
|
+
//
|
|
215
|
+
// The provenance lifecycle store makes that actively destructive rather
|
|
216
|
+
// than merely noisy: updateLifecycle marks every open stableId ABSENT from
|
|
217
|
+
// the finding set it is handed as `remediated`, and this set is one file's
|
|
218
|
+
// worth of findings. Every save would remediate the whole project, and the
|
|
219
|
+
// next real scan would reintroduce it.
|
|
220
|
+
//
|
|
221
|
+
// Chosen over forwarding `provenance:false` through runScan because that
|
|
222
|
+
// would fix only the lifecycle half and leave the other state writers
|
|
223
|
+
// firing. The flag is process-global (see its KNOWN LIMITATION), which is
|
|
224
|
+
// harmless here: this server is a read-only surface whose every scan wants
|
|
225
|
+
// writes off, so overlapping saves can only ever agree, and the `finally`
|
|
226
|
+
// restores the prior value either way. exceptCategories:['provenance-cache']
|
|
227
|
+
// (M2 §2.4) is the one deliberate exception — every OTHER write this scan
|
|
228
|
+
// would make stays suppressed, but the provenance disk cache stays live so
|
|
229
|
+
// repeated saves of the same file are not each paying the full uncached
|
|
230
|
+
// resolution cost.
|
|
231
|
+
const { scan } = await (0,_posture_state_dir_js__WEBPACK_IMPORTED_MODULE_7__/* .withStateWritesDisabled */ .Ao)(() =>
|
|
232
|
+
(0,_runScan_js__WEBPACK_IMPORTED_MODULE_5__.runScan)(_rootDir, { fileContents, depFileContents, deep: true, deepInCi: true }),
|
|
233
|
+
{ exceptCategories: ['provenance-cache'] });
|
|
234
|
+
// Stage 6 correctness audit: this only ever read scan.findings (the SAST
|
|
235
|
+
// channel). scan.secrets and scan.logicVulns are separate arrays on the
|
|
236
|
+
// raw runScan() result — normalizeFindings is what merges all four
|
|
237
|
+
// channels, and that hasn't run here — so a saved file with a hardcoded
|
|
238
|
+
// credential got a clean problem pane, no diagnostic at all. Unlike the
|
|
239
|
+
// MCP surface, this server never applied redactFinding either (nothing
|
|
240
|
+
// here imported mcp/redact.js), which would have been a landmine the
|
|
241
|
+
// moment secrets/logicVulns were added without it: those channels are
|
|
242
|
+
// exactly where raw secret material shows up in `snippet`. Both fixed
|
|
243
|
+
// together — merge the channels AND redact — so the fix for one gap
|
|
244
|
+
// doesn't open the other.
|
|
245
|
+
const findings = [...(scan.findings || []), ...(scan.secrets || []), ...(scan.logicVulns || [])]
|
|
246
|
+
.filter(f => f.file === rel)
|
|
247
|
+
.map(f => (0,_mcp_redact_js__WEBPACK_IMPORTED_MODULE_9__/* .redactFinding */ .lE)(f));
|
|
248
|
+
await publishDiagnostics(uri, findings);
|
|
249
|
+
} catch (e) {
|
|
250
|
+
process.stderr.write(`agentic-security-lsp: scan failed: ${e.message}\n`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function handleInitialize(params) {
|
|
255
|
+
if (params.rootUri) {
|
|
256
|
+
_rootUri = params.rootUri;
|
|
257
|
+
_rootDir = uriToPath(params.rootUri) || process.cwd();
|
|
258
|
+
} else if (params.rootPath) {
|
|
259
|
+
_rootDir = params.rootPath;
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
capabilities: {
|
|
263
|
+
textDocumentSync: {
|
|
264
|
+
openClose: true,
|
|
265
|
+
change: 1,
|
|
266
|
+
save: { includeText: false },
|
|
267
|
+
},
|
|
268
|
+
diagnosticProvider: { interFileDependencies: true, workspaceDiagnostics: false },
|
|
269
|
+
// quick fixes for Haskell and Nix findings (preview edits from the verified language fixers)
|
|
270
|
+
codeActionProvider: { codeActionKinds: ['quickfix'], resolveProvider: false },
|
|
271
|
+
},
|
|
272
|
+
serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* textDocument/codeAction: a quick fix for each agentic-security diagnostic in range that a language fixer can repair.
|
|
278
|
+
* The edit is a PREVIEW the editor applies; nothing is written here. A finding with no safe fix gets a disabled
|
|
279
|
+
* action that says why, never a silent omission.
|
|
280
|
+
*/
|
|
281
|
+
async function codeActionsFor(params) {
|
|
282
|
+
const uri = params && params.textDocument && params.textDocument.uri;
|
|
283
|
+
const diags = ((params && params.context && params.context.diagnostics) || []).filter((d) => d && d.source === 'agentic-security' && d.data && d.data.id);
|
|
284
|
+
const stored = (uri && _diagnosticsByUri.get(uri)) || [];
|
|
285
|
+
if (!diags.length || !stored.length) return [];
|
|
286
|
+
let files = null;
|
|
287
|
+
const actions = [];
|
|
288
|
+
for (const d of diags) {
|
|
289
|
+
const f = stored.find((x) => x && x.id === d.data.id);
|
|
290
|
+
if (!f || !(0,_language_context_js__WEBPACK_IMPORTED_MODULE_1__.languageOfFinding)(f)) continue;
|
|
291
|
+
if (!files) files = (0,_language_context_js__WEBPACK_IMPORTED_MODULE_1__.loadLanguageProject)(_rootDir).files;
|
|
292
|
+
let prev;
|
|
293
|
+
try { prev = await (0,_language_context_js__WEBPACK_IMPORTED_MODULE_1__.languageFixPreview)(f, files); } catch (e) { prev = { ok: false, reason: String((e && e.message) || e) }; }
|
|
294
|
+
if (prev && prev.ok && prev.file) {
|
|
295
|
+
const target = pathToUri(node_path__WEBPACK_IMPORTED_MODULE_3__.join(_rootDir, prev.file));
|
|
296
|
+
actions.push({
|
|
297
|
+
title: `Fix${prev.label ? ` (${prev.label})` : ''}: ${f.vuln || f.rule || 'finding'}`,
|
|
298
|
+
kind: 'quickfix', diagnostics: [d], isPreferred: false,
|
|
299
|
+
edit: { changes: { [target]: [(0,_language_context_js__WEBPACK_IMPORTED_MODULE_1__.minimalEdit)(prev.before, prev.after)] } },
|
|
300
|
+
data: { stableId: f.stableId || null, verified: { syntax: true, rescan: true } },
|
|
301
|
+
});
|
|
302
|
+
} else {
|
|
303
|
+
actions.push({ title: `No automatic fix: ${f.vuln || f.rule || 'finding'}`, kind: 'quickfix', diagnostics: [d], disabled: { reason: (prev && (prev.reason || prev.status)) || 'no deterministic fix exists for this finding' } });
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return actions;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function handleMessage(msg) {
|
|
310
|
+
if (msg.method === 'textDocument/codeAction') {
|
|
311
|
+
return { id: msg.id, result: await codeActionsFor(msg.params || {}) };
|
|
312
|
+
}
|
|
313
|
+
if (msg.method === 'initialize') {
|
|
314
|
+
return { id: msg.id, result: handleInitialize(msg.params || {}) };
|
|
315
|
+
}
|
|
316
|
+
if (msg.method === 'initialized' || msg.method === 'workspace/didChangeConfiguration') {
|
|
317
|
+
return null; // notification, no response
|
|
318
|
+
}
|
|
319
|
+
if (msg.method === 'shutdown') {
|
|
320
|
+
return { id: msg.id, result: null };
|
|
321
|
+
}
|
|
322
|
+
if (msg.method === 'exit') {
|
|
323
|
+
process.exit(0);
|
|
324
|
+
}
|
|
325
|
+
if (msg.method === 'textDocument/didOpen') {
|
|
326
|
+
const uri = msg.params?.textDocument?.uri;
|
|
327
|
+
if (uri) scanFile(uri);
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
if (msg.method === 'textDocument/didSave') {
|
|
331
|
+
const uri = msg.params?.textDocument?.uri;
|
|
332
|
+
if (uri) {
|
|
333
|
+
// Premortem 3R-9 / 4R-5: when the user saves a manifest file, the
|
|
334
|
+
// dep-cache entry for THAT file is stale. Granular invalidation (only
|
|
335
|
+
// re-read the saved file from disk) avoids the O(project) re-walk that
|
|
336
|
+
// 3R-9 introduced — important in monorepos where mass manifest edits
|
|
337
|
+
// would otherwise re-scan thousands of files per save.
|
|
338
|
+
const savedPath = uriToPath(uri);
|
|
339
|
+
if (savedPath && _depCache.rootDir === _rootDir) {
|
|
340
|
+
const base = node_path__WEBPACK_IMPORTED_MODULE_3__.basename(savedPath);
|
|
341
|
+
if (DEP_BASE_NAMES.has(base) || DEP_EXT_RE.test(base) || DEP_NAME_RE.test(base) || _isLanguageDep(base)) {
|
|
342
|
+
try {
|
|
343
|
+
const rel = node_path__WEBPACK_IMPORTED_MODULE_3__.relative(_rootDir, savedPath);
|
|
344
|
+
const st = node_fs__WEBPACK_IMPORTED_MODULE_2__.statSync(savedPath);
|
|
345
|
+
if (st.size <= 500_000) {
|
|
346
|
+
_depCache.depFileContents[rel] = node_fs__WEBPACK_IMPORTED_MODULE_2__.readFileSync(savedPath, 'utf8');
|
|
347
|
+
} else {
|
|
348
|
+
delete _depCache.depFileContents[rel];
|
|
349
|
+
}
|
|
350
|
+
} catch {
|
|
351
|
+
// File vanished between save event and stat — drop from cache.
|
|
352
|
+
try {
|
|
353
|
+
const rel = node_path__WEBPACK_IMPORTED_MODULE_3__.relative(_rootDir, savedPath);
|
|
354
|
+
delete _depCache.depFileContents[rel];
|
|
355
|
+
} catch {}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
scanFile(uri);
|
|
360
|
+
}
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
if (msg.method === 'textDocument/didClose') {
|
|
364
|
+
const uri = msg.params?.textDocument?.uri;
|
|
365
|
+
if (uri) await publishDiagnostics(uri, []);
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
// Unknown method.
|
|
369
|
+
if (msg.id != null) {
|
|
370
|
+
return { id: msg.id, error: { code: -32601, message: `Method not found: ${msg.method}` } };
|
|
371
|
+
}
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function startLspServer() {
|
|
376
|
+
let buffer = Buffer.alloc(0);
|
|
377
|
+
let expected = -1;
|
|
378
|
+
process.stdin.on('data', async (chunk) => {
|
|
379
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
380
|
+
while (true) {
|
|
381
|
+
if (expected < 0) {
|
|
382
|
+
const headerEnd = buffer.indexOf('\r\n\r\n');
|
|
383
|
+
if (headerEnd < 0) break;
|
|
384
|
+
const headers = buffer.slice(0, headerEnd).toString('utf8');
|
|
385
|
+
const m = headers.match(/Content-Length:\s*(\d+)/i);
|
|
386
|
+
if (!m) {
|
|
387
|
+
process.stderr.write('agentic-security-lsp: missing Content-Length header\n');
|
|
388
|
+
buffer = buffer.slice(headerEnd + 4);
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
expected = parseInt(m[1], 10);
|
|
392
|
+
buffer = buffer.slice(headerEnd + 4);
|
|
393
|
+
}
|
|
394
|
+
if (buffer.length < expected) break;
|
|
395
|
+
const body = buffer.slice(0, expected).toString('utf8');
|
|
396
|
+
buffer = buffer.slice(expected);
|
|
397
|
+
expected = -1;
|
|
398
|
+
let msg;
|
|
399
|
+
try { msg = JSON.parse(body); }
|
|
400
|
+
catch { process.stderr.write('agentic-security-lsp: malformed JSON\n'); continue; }
|
|
401
|
+
try {
|
|
402
|
+
const response = await handleMessage(msg);
|
|
403
|
+
if (response) await send(response);
|
|
404
|
+
} catch (e) {
|
|
405
|
+
if (msg.id != null) await send({ id: msg.id, error: { code: -32603, message: e.message } });
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
process.stdin.on('end', () => process.exit(0));
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Allow direct invocation as a bin entry: `node lsp/server.js`.
|
|
413
|
+
//
|
|
414
|
+
// `import.meta.url === file://${process.argv[1]}` looks equivalent but is
|
|
415
|
+
// NOT: when this script is invoked through a symlink (exactly what
|
|
416
|
+
// `npm install -g`, `npx`, and `node_modules/.bin/<name>` all do for a
|
|
417
|
+
// package's `bin` entries — and `agentic-security-lsp` IS one of this
|
|
418
|
+
// package's bin entries), Node resolves `import.meta.url` to the symlink's
|
|
419
|
+
// realpath while `process.argv[1]` stays the symlink path as invoked, so the
|
|
420
|
+
// two never match, the guard is always false, and the server silently exits
|
|
421
|
+
// with no output — an editor would see the language server start and
|
|
422
|
+
// immediately die with nothing on stderr to explain it. `import.meta.main` is
|
|
423
|
+
// resolved correctly through a symlink. It was added in Node v24.2.0
|
|
424
|
+
// (backported to v22.18.0) and is currently Stability 1.0 (early development)
|
|
425
|
+
// per Node's own docs — NOT stable, and NOT available on v20.11. Concretely:
|
|
426
|
+
// it is `undefined` on Node 24.0.0/24.1.x, which satisfy this repo's declared
|
|
427
|
+
// `engines.node: ">=24.0.0"` floor, so `import.meta.main` alone would
|
|
428
|
+
// reproduce this exact bug on a plain non-symlinked invocation under those two
|
|
429
|
+
// point releases. The `??` fallback covers that gap without bumping the
|
|
430
|
+
// engines floor. Identical to bin/agentic-security.js's guard, deliberately —
|
|
431
|
+
// see the long-form note there.
|
|
432
|
+
if (import.meta.main ?? (import.meta.url === `file://${process.argv[1]}`)) {
|
|
433
|
+
startLspServer();
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function _setRootDir(dir) { _rootDir = dir; _depCache = { rootDir: null, depFileContents: {} }; }
|
|
437
|
+
|
|
438
|
+
const _internals = { findingToDiagnostic, scanFile, uriToPath, pathToUri, _diagnosticsByUri, _setRootDir };
|
|
439
|
+
|
|
440
|
+
__webpack_async_result__();
|
|
441
|
+
} catch(e) { __webpack_async_result__(e); } });
|
|
442
|
+
|
|
443
|
+
/***/ })
|
|
444
|
+
|
|
445
|
+
};
|
package/dist/2862.index.js
CHANGED
|
@@ -261,14 +261,14 @@ async function assessLanguageAssurance(input = {}) {
|
|
|
261
261
|
const scaRequired = (required.haskell || []).includes('sca') && !disabled.has('haskell:sca');
|
|
262
262
|
if (present.has('haskell') && scaRequired && manifests.length) {
|
|
263
263
|
const db = input.advisoryDb;
|
|
264
|
-
if (!db) conditions.push(
|
|
264
|
+
if (!db) conditions.push(`no Hackage advisory snapshot is loaded: Haskell dependency vulnerabilities were not assessed${input.advisoryReason ? `. ${input.advisoryReason}` : ''}`);
|
|
265
265
|
else if (db.stale) conditions.push(`the Hackage advisory snapshot is stale (${db.ageDays == null ? 'age unknown' : `${Math.floor(db.ageDays)} day(s) old`}, limit ${db.maxAgeDays})`);
|
|
266
266
|
}
|
|
267
267
|
|
|
268
268
|
// 3b. resolved dependency data and the Nix closure: a stale, malformed or unchecked input is stated, never silently dropped
|
|
269
269
|
for (const g of input.supplyGaps || []) {
|
|
270
270
|
if (!g || !g.kind) continue;
|
|
271
|
-
if (/unavailable|stale|malformed|failed|unverified/.test(g.kind)) conditions.push(`${g.kind.replace(/^(?:resolved|closure)-/, '')}: ${String(g.detail || '').slice(0, 220)}`);
|
|
271
|
+
if (/unavailable|stale|malformed|failed|unverified|refused|contradicts/.test(g.kind)) conditions.push(`${g.kind.replace(/^(?:resolved|closure)-/, '')}: ${String(g.detail || '').slice(0, 220)}`);
|
|
272
272
|
else limitations.push({ kind: 'supply-gap', gap: g.kind, note: String(g.detail || '').slice(0, 220) });
|
|
273
273
|
}
|
|
274
274
|
|