@clear-capabilities/agentic-security-scanner 0.79.0 โ†’ 0.84.1

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.
Files changed (122) hide show
  1. package/dist/178.index.js +1 -1
  2. package/dist/333.index.js +283 -0
  3. package/dist/384.index.js +1 -1
  4. package/dist/637.index.js +1 -1
  5. package/dist/838.index.js +1 -1
  6. package/dist/839.index.js +170 -0
  7. package/dist/985.index.js +140 -1
  8. package/dist/agentic-security.mjs +10 -10
  9. package/dist/agentic-security.mjs.sha256 +1 -1
  10. package/package.json +7 -5
  11. package/src/.agentic-security/findings.json +117732 -0
  12. package/src/.agentic-security/last-scan.json +117732 -0
  13. package/src/.agentic-security/last-scan.json.sig +1 -0
  14. package/src/.agentic-security/scan-history.json +12946 -0
  15. package/src/.agentic-security/streak.json +21 -0
  16. package/src/dataflow/.agentic-security/findings.json +6086 -0
  17. package/src/dataflow/.agentic-security/last-scan.json +6086 -0
  18. package/src/dataflow/.agentic-security/last-scan.json.sig +1 -0
  19. package/src/dataflow/.agentic-security/scan-history.json +250 -0
  20. package/src/dataflow/.agentic-security/streak.json +21 -0
  21. package/src/dataflow/cross-service-taint.js +201 -0
  22. package/src/dataflow/formal-verify.js +204 -0
  23. package/src/dataflow/ifds-precise.js +222 -0
  24. package/src/dataflow/k2-summary-cache.js +153 -0
  25. package/src/dataflow/lib-taint-summaries.js +198 -0
  26. package/src/dataflow/privacy-taint.js +205 -0
  27. package/src/dataflow/smt-feasibility.js +189 -0
  28. package/src/engine.js +825 -127
  29. package/src/ir/.agentic-security/findings.json +4011 -0
  30. package/src/ir/.agentic-security/last-scan.json +4011 -0
  31. package/src/ir/.agentic-security/last-scan.json.sig +1 -0
  32. package/src/ir/.agentic-security/scan-history.json +193 -0
  33. package/src/ir/.agentic-security/streak.json +20 -0
  34. package/src/ir/cpp-preprocessor.js +142 -0
  35. package/src/ir/csharp-ir.js +604 -0
  36. package/src/ir/universal-ir.js +403 -0
  37. package/src/mcp/.agentic-security/findings.json +8632 -0
  38. package/src/mcp/.agentic-security/last-scan.json +8632 -0
  39. package/src/mcp/.agentic-security/last-scan.json.sig +1 -0
  40. package/src/mcp/.agentic-security/scan-history.json +331 -0
  41. package/src/mcp/.agentic-security/streak.json +20 -0
  42. package/src/mcp/tools.js +140 -1
  43. package/src/posture/.agentic-security/findings.json +77181 -0
  44. package/src/posture/.agentic-security/last-scan.json +77181 -0
  45. package/src/posture/.agentic-security/last-scan.json.sig +1 -0
  46. package/src/posture/.agentic-security/scan-history.json +8904 -0
  47. package/src/posture/.agentic-security/streak.json +21 -0
  48. package/src/posture/api-contract.js +193 -0
  49. package/src/posture/attack-taxonomy.js +227 -0
  50. package/src/posture/auditor-walkthrough.js +252 -0
  51. package/src/posture/claude-authorship.js +197 -0
  52. package/src/posture/compliance-frameworks/.agentic-security/findings.json +80 -0
  53. package/src/posture/compliance-frameworks/.agentic-security/last-scan.json +80 -0
  54. package/src/posture/compliance-frameworks/.agentic-security/last-scan.json.sig +1 -0
  55. package/src/posture/compliance-frameworks/.agentic-security/scan-history.json +90 -0
  56. package/src/posture/compliance-frameworks/.agentic-security/streak.json +22 -0
  57. package/src/posture/compliance-frameworks/ccpa.json +32 -0
  58. package/src/posture/compliance-frameworks/eu-ai-act.json +51 -0
  59. package/src/posture/compliance-frameworks/gdpr.json +45 -0
  60. package/src/posture/compliance-frameworks/hipaa-security-rule.json +56 -0
  61. package/src/posture/compliance-frameworks/nist-ai-600-1.json +51 -0
  62. package/src/posture/compliance-frameworks/nist-csf-2.json +73 -0
  63. package/src/posture/compliance-frameworks/owasp-asvs-5.json +79 -0
  64. package/src/posture/compliance-frameworks/owasp-llm-top-10.json +69 -0
  65. package/src/posture/compliance-policy.js +218 -0
  66. package/src/posture/composite-risk.js +122 -0
  67. package/src/posture/cross-repo-memory.js +180 -0
  68. package/src/posture/csharp-analysis.js +330 -0
  69. package/src/posture/dep-add-guard.js +197 -0
  70. package/src/posture/exploit-bundle.js +210 -0
  71. package/src/posture/federated-learning.js +172 -0
  72. package/src/posture/findings-memory.js +152 -0
  73. package/src/posture/fix-style-mirror.js +118 -0
  74. package/src/posture/git-history.js +141 -0
  75. package/src/posture/intent-context.js +175 -0
  76. package/src/posture/license-attributions.js +94 -0
  77. package/src/posture/license-graph.js +238 -0
  78. package/src/posture/model-rescan.js +76 -0
  79. package/src/posture/pattern-propagation.js +39 -0
  80. package/src/posture/pqc-migration-plan.js +158 -0
  81. package/src/posture/pr-augment.js +234 -0
  82. package/src/posture/reachability-filter.js +33 -2
  83. package/src/posture/realtime-cve-monitor.js +214 -0
  84. package/src/posture/risk-dollars.js +158 -0
  85. package/src/posture/runtime-correlation.js +174 -0
  86. package/src/posture/sbom-diff.js +171 -0
  87. package/src/posture/sca-policy.js +235 -0
  88. package/src/posture/sca-upgrade.js +259 -0
  89. package/src/posture/threat-model-auto.js +268 -0
  90. package/src/posture/threat-model-grounding.js +169 -0
  91. package/src/posture/time-to-fix.js +129 -0
  92. package/src/posture/triage-learning.js +170 -0
  93. package/src/posture/triage-memory.js +151 -0
  94. package/src/posture/triage.js +40 -1
  95. package/src/posture/watch-mode.js +171 -0
  96. package/src/posture/workflow-installer.js +231 -0
  97. package/src/sast/.agentic-security/findings.json +6154 -0
  98. package/src/sast/.agentic-security/last-scan.json +6154 -0
  99. package/src/sast/.agentic-security/last-scan.json.sig +1 -0
  100. package/src/sast/.agentic-security/scan-history.json +941 -0
  101. package/src/sast/.agentic-security/streak.json +22 -0
  102. package/src/sast/_secret-entropy.js +145 -0
  103. package/src/sast/cloud-iam.js +312 -0
  104. package/src/sast/cpp.js +138 -4
  105. package/src/sast/crypto-protocol.js +388 -0
  106. package/src/sast/csharp-tokenizer.js +392 -0
  107. package/src/sast/csharp.js +924 -138
  108. package/src/sast/dapp-frontend.js +200 -0
  109. package/src/sast/k8s-admission.js +271 -0
  110. package/src/sast/llm-app.js +272 -0
  111. package/src/sast/ml-supply-chain.js +259 -0
  112. package/src/sast/mobile.js +224 -0
  113. package/src/sast/post-quantum-crypto.js +348 -0
  114. package/src/sast/web3-advanced.js +375 -0
  115. package/src/sca/.agentic-security/findings.json +7460 -0
  116. package/src/sca/.agentic-security/last-scan.json +7460 -0
  117. package/src/sca/.agentic-security/last-scan.json.sig +1 -0
  118. package/src/sca/.agentic-security/scan-history.json +113 -0
  119. package/src/sca/.agentic-security/streak.json +21 -0
  120. package/src/sca/CLAUDE.md +161 -0
  121. package/src/sca/binary-metadata.js +37 -15
  122. package/src/sca/sigstore-verify.js +215 -0
@@ -0,0 +1,170 @@
1
+ // Continuous learning from triage decisions โ€” Recommendation #8 of the
2
+ // world-class roadmap.
3
+ //
4
+ // Every triage transition (open โ†’ fixed | wont-fix | false-positive)
5
+ // auto-tunes a per-(project, family, file-glob, sink-method) calibration
6
+ // store. The store directly modifies finding confidence scores on
7
+ // subsequent scans, so the scanner's precision on each individual
8
+ // codebase improves monotonically the longer it runs.
9
+ //
10
+ // Calibration shape:
11
+ //
12
+ // { "global": { // per-(family, sink-method) prior across all projects
13
+ // "sql-injection|.executeQuery": { tp: 142, fp: 7, lastUpdated: "..." }
14
+ // },
15
+ // "perProject": { // per-(file-glob, family) project-specific delta
16
+ // "src/admin/**|hardcoded-secret": { tp: 0, fp: 23, lastUpdated: "..." }
17
+ // }
18
+ // }
19
+ //
20
+ // Update rule on triage:
21
+ // transition โ†’ 'fixed' / 'wont-fix-because-not-exploitable' counts as TP+1
22
+ // transition โ†’ 'false-positive' counts as FP+1
23
+ //
24
+ // Application rule at scan time:
25
+ // confidence *= bayesianFactor(prior, project)
26
+ // where bayesianFactor uses a beta-distribution update of the priors.
27
+
28
+ import * as fs from 'node:fs';
29
+ import * as path from 'node:path';
30
+ import { statePath, safeWriteState } from './state-dir.js';
31
+
32
+ const CALIBRATION_FILE = 'triage-calibration.json';
33
+
34
+ // Minimum sample size before per-project calibration takes effect. With
35
+ // fewer than this many triage decisions, we fall back to global priors.
36
+ const MIN_PROJECT_SAMPLES = 5;
37
+
38
+ // Bayesian prior โ€” beta(ฮฑ, ฮฒ) over the precision rate. ฮฑ=1, ฮฒ=1 is a
39
+ // uniform prior (no opinion); ฮฑ=2, ฮฒ=1 mildly favors precision.
40
+ const PRIOR_ALPHA = 2;
41
+ const PRIOR_BETA = 1;
42
+
43
+ function _storePath(scanRoot) {
44
+ return statePath(scanRoot, CALIBRATION_FILE);
45
+ }
46
+
47
+ export function loadCalibration(scanRoot) {
48
+ const fp = _storePath(scanRoot);
49
+ if (!fs.existsSync(fp)) return { global: {}, perProject: {} };
50
+ try { return JSON.parse(fs.readFileSync(fp, 'utf8')); }
51
+ catch { return { global: {}, perProject: {} }; }
52
+ }
53
+
54
+ function _save(scanRoot, data) {
55
+ const fp = _storePath(scanRoot);
56
+ safeWriteState(fp, JSON.stringify(data, null, 2));
57
+ }
58
+
59
+ function _bucketKey(family, sinkMethod) {
60
+ return `${family || 'unknown'}|${sinkMethod || ''}`;
61
+ }
62
+
63
+ function _projectKey(fileGlob, family) {
64
+ return `${fileGlob || '*'}|${family || 'unknown'}`;
65
+ }
66
+
67
+ /**
68
+ * Record a triage decision. Called by the triage transition path.
69
+ * verdict โˆˆ { 'true-positive', 'false-positive' }
70
+ */
71
+ export function recordTriageDecision(scanRoot, finding, verdict) {
72
+ if (!finding || !verdict) return null;
73
+ const data = loadCalibration(scanRoot);
74
+ const family = finding.family;
75
+ const sinkMethod = _extractSinkMethod(finding);
76
+ const fileGlob = _fileGlobFor(finding.file);
77
+ const now = new Date().toISOString();
78
+
79
+ const gk = _bucketKey(family, sinkMethod);
80
+ data.global[gk] ||= { tp: 0, fp: 0, lastUpdated: now };
81
+ if (verdict === 'true-positive') data.global[gk].tp++;
82
+ if (verdict === 'false-positive') data.global[gk].fp++;
83
+ data.global[gk].lastUpdated = now;
84
+
85
+ const pk = _projectKey(fileGlob, family);
86
+ data.perProject[pk] ||= { tp: 0, fp: 0, lastUpdated: now };
87
+ if (verdict === 'true-positive') data.perProject[pk].tp++;
88
+ if (verdict === 'false-positive') data.perProject[pk].fp++;
89
+ data.perProject[pk].lastUpdated = now;
90
+
91
+ _save(scanRoot, data);
92
+ return { gk, pk, data };
93
+ }
94
+
95
+ /**
96
+ * Apply learned calibration to a fresh batch of findings. Modifies
97
+ * confidence in place. Returns { adjusted: int, suppressed: int }
98
+ * โ€” findings whose adjusted confidence drops below `suppressThreshold`
99
+ * are filtered out (added to the suppression log instead).
100
+ */
101
+ export function applyLearnedCalibration(scanRoot, findings, opts = {}) {
102
+ if (!Array.isArray(findings)) return { adjusted: 0, suppressed: 0 };
103
+ const data = loadCalibration(scanRoot);
104
+ const suppressThreshold = opts.suppressThreshold ?? 0.2;
105
+ let adjusted = 0;
106
+ const suppressedList = [];
107
+ for (const f of findings) {
108
+ const factor = _learnedFactor(data, f);
109
+ if (factor === 1.0) continue;
110
+ const before = typeof f.confidence === 'number' ? f.confidence : 0.85;
111
+ const after = Math.max(0.01, Math.min(0.99, before * factor));
112
+ f.confidence = after;
113
+ f._learnedCalibration = { factor, before, samples: _sampleCount(data, f) };
114
+ adjusted++;
115
+ if (after < suppressThreshold) {
116
+ f._suppressed_by = 'triage-learning';
117
+ suppressedList.push(f);
118
+ }
119
+ }
120
+ return { adjusted, suppressed: suppressedList.length, suppressedList, data };
121
+ }
122
+
123
+ function _learnedFactor(data, finding) {
124
+ const family = finding.family;
125
+ const sinkMethod = _extractSinkMethod(finding);
126
+ const fileGlob = _fileGlobFor(finding.file);
127
+
128
+ // Per-project: beta-distribution precision estimate when N is large enough.
129
+ const pk = _projectKey(fileGlob, family);
130
+ const proj = data.perProject?.[pk];
131
+ if (proj && proj.tp + proj.fp >= MIN_PROJECT_SAMPLES) {
132
+ const p = (PRIOR_ALPHA + proj.tp) / (PRIOR_ALPHA + PRIOR_BETA + proj.tp + proj.fp);
133
+ return p / 0.5; // 0.5 = neutral prior precision
134
+ }
135
+ // Global: same formula, less aggressive scaling.
136
+ const gk = _bucketKey(family, sinkMethod);
137
+ const glob = data.global?.[gk];
138
+ if (glob && glob.tp + glob.fp >= MIN_PROJECT_SAMPLES) {
139
+ const p = (PRIOR_ALPHA + glob.tp) / (PRIOR_ALPHA + PRIOR_BETA + glob.tp + glob.fp);
140
+ return (0.7 * (p / 0.5)) + 0.3; // weight: 70% global, 30% neutral
141
+ }
142
+ return 1.0;
143
+ }
144
+
145
+ function _sampleCount(data, finding) {
146
+ const family = finding.family;
147
+ const sinkMethod = _extractSinkMethod(finding);
148
+ const fileGlob = _fileGlobFor(finding.file);
149
+ const pk = _projectKey(fileGlob, family);
150
+ const gk = _bucketKey(family, sinkMethod);
151
+ const proj = data.perProject?.[pk] || { tp: 0, fp: 0 };
152
+ const glob = data.global?.[gk] || { tp: 0, fp: 0 };
153
+ return { perProject: proj.tp + proj.fp, global: glob.tp + glob.fp };
154
+ }
155
+
156
+ function _extractSinkMethod(finding) {
157
+ if (finding.sink?.method) return finding.sink.method;
158
+ // Try to parse from the vuln string ("SQL Injection โ€” executeQuery").
159
+ const m = (finding.vuln || '').match(/\b([A-Za-z_]\w*)\s*\(/);
160
+ return m ? m[1] : '';
161
+ }
162
+
163
+ function _fileGlobFor(file) {
164
+ if (!file) return '*';
165
+ const parts = file.split('/');
166
+ if (parts.length <= 2) return file;
167
+ return parts.slice(0, 2).join('/') + '/**';
168
+ }
169
+
170
+ export const _internals = { _bucketKey, _projectKey, _extractSinkMethod, _fileGlobFor, MIN_PROJECT_SAMPLES };
@@ -0,0 +1,151 @@
1
+ // Triage memory โ€” conversational triage memory layer.
2
+ //
3
+ // When a finding transitions to wont-fix or false-positive (via the
4
+ // triage CLI / MCP tool / agent action), this module:
5
+ //
6
+ // 1. writes a structured entry to AGENTS.md so the lesson is captured
7
+ // for next session (continual-learning surface),
8
+ // 2. records the decision shape (family + file-glob + reason) into
9
+ // .agentic-security/triage-memory.jsonl for fast pattern matching,
10
+ // 3. provides suppressByPastDecisions() โ€” an annotator that pre-demotes
11
+ // findings whose (family, file-glob) was previously marked wont-fix
12
+ // or false-positive in the same project.
13
+ //
14
+ // This is the dialogue-aware analogue to posture/triage-learning.js,
15
+ // which adjusts confidence calibration from triage counts. triage-memory
16
+ // works on the discrete narrative ("we decided this is fine, here's
17
+ // why") rather than on calibration math.
18
+ //
19
+ // Opt-out: AGENTIC_SECURITY_NO_TRIAGE_MEMORY=1
20
+
21
+ import * as fs from 'node:fs';
22
+ import * as path from 'node:path';
23
+
24
+ const STATE_DIR = '.agentic-security';
25
+ const MEMORY_FILE = 'triage-memory.jsonl';
26
+ const AGENTS_FILE = 'AGENTS.md';
27
+
28
+ function _stateDir(scanRoot) { return path.join(scanRoot, STATE_DIR); }
29
+ function _memPath(scanRoot) { return path.join(_stateDir(scanRoot), MEMORY_FILE); }
30
+ function _agentsPath(scanRoot) { return path.join(_stateDir(scanRoot), AGENTS_FILE); }
31
+
32
+ function _bucketKey(finding) {
33
+ const file = finding.file || finding.file_path || '';
34
+ const dir = file.split('/').slice(0, -1).join('/') || '.';
35
+ return `${finding.family || finding.parser || 'unknown'}::${dir}`;
36
+ }
37
+
38
+ /**
39
+ * Record a triage decision into both AGENTS.md (narrative) and
40
+ * triage-memory.jsonl (structured). Idempotent โ€” repeated calls add a
41
+ * single new line; existing entries are not deduped (history is a feature).
42
+ */
43
+ export function recordDecision(scanRoot, finding, decision, reason) {
44
+ if (!scanRoot || !finding || !decision) return null;
45
+ if (!['wont-fix', 'false-positive'].includes(decision)) return null;
46
+ try { fs.mkdirSync(_stateDir(scanRoot), { recursive: true }); } catch {}
47
+
48
+ const entry = {
49
+ at: new Date().toISOString(),
50
+ decision,
51
+ reason: String(reason || '').slice(0, 280),
52
+ bucket: _bucketKey(finding),
53
+ family: finding.family || null,
54
+ severity: finding.severity || null,
55
+ cwe: finding.cwe || null,
56
+ vuln: (finding.vuln || finding.title || '').slice(0, 160),
57
+ file: finding.file || finding.file_path || '',
58
+ line: finding.line || 0,
59
+ id: finding.id || finding.stableId || null,
60
+ };
61
+ try { fs.appendFileSync(_memPath(scanRoot), JSON.stringify(entry) + '\n'); } catch {}
62
+
63
+ // Narrative entry in AGENTS.md โ€” short, human-readable, surfaced at SessionStart.
64
+ const narrative = [
65
+ `## Triage decision โ€” ${entry.at.slice(0, 10)} (${decision})`,
66
+ `- Finding: ${entry.vuln || entry.family || 'unknown'} at ${entry.file}:${entry.line}`,
67
+ `- Bucket: \`${entry.bucket}\``,
68
+ reason ? `- Why: ${entry.reason}` : null,
69
+ `- Future scans should treat similar findings under this bucket as already-reviewed.`,
70
+ '',
71
+ ].filter(Boolean).join('\n');
72
+ try { fs.appendFileSync(_agentsPath(scanRoot), '\n' + narrative); } catch {}
73
+
74
+ return entry;
75
+ }
76
+
77
+ /**
78
+ * Load past decisions from triage-memory.jsonl.
79
+ */
80
+ export function loadMemory(scanRoot) {
81
+ const fp = _memPath(scanRoot);
82
+ if (!fs.existsSync(fp)) return [];
83
+ try {
84
+ return fs.readFileSync(fp, 'utf8')
85
+ .split('\n').filter(Boolean)
86
+ .map(l => { try { return JSON.parse(l); } catch { return null; } })
87
+ .filter(Boolean);
88
+ } catch { return []; }
89
+ }
90
+
91
+ /**
92
+ * Annotator: for each finding, check if its (family, file-glob) bucket
93
+ * was previously marked wont-fix or false-positive in this project. If
94
+ * so, attach `pastDecision` metadata and lower confidence.
95
+ *
96
+ * Does NOT remove findings โ€” only annotates. UI / report layer decides
97
+ * how to display.
98
+ */
99
+ export function suppressByPastDecisions(scanRoot, findings) {
100
+ if (process.env.AGENTIC_SECURITY_NO_TRIAGE_MEMORY === '1') return { applied: 0 };
101
+ if (!Array.isArray(findings) || findings.length === 0) return { applied: 0 };
102
+ const memory = loadMemory(scanRoot);
103
+ if (!memory.length) return { applied: 0 };
104
+
105
+ // Build bucket โ†’ most-recent decision map.
106
+ const bucketDecision = new Map();
107
+ for (const e of memory) {
108
+ if (!bucketDecision.has(e.bucket) || bucketDecision.get(e.bucket).at < e.at) {
109
+ bucketDecision.set(e.bucket, e);
110
+ }
111
+ }
112
+
113
+ let applied = 0;
114
+ for (const f of findings) {
115
+ const key = _bucketKey(f);
116
+ const past = bucketDecision.get(key);
117
+ if (!past) continue;
118
+ f.pastDecision = {
119
+ decision: past.decision,
120
+ at: past.at,
121
+ reason: past.reason,
122
+ sameBucket: true,
123
+ };
124
+ // Soft demote: drop confidence and add a tag explaining why.
125
+ if (typeof f.confidence === 'number') f.confidence = Math.max(0.2, f.confidence * 0.5);
126
+ f.tags = Array.isArray(f.tags) ? f.tags : [];
127
+ if (!f.tags.includes('past-decision')) f.tags.push('past-decision');
128
+ applied++;
129
+ }
130
+ return { applied, total: findings.length };
131
+ }
132
+
133
+ /**
134
+ * Search past triage decisions by natural-language query terms. Naive
135
+ * keyword match for v1 โ€” sufficient when memory is < 1000 entries.
136
+ * Future: vector search against an embedding store.
137
+ */
138
+ export function queryMemory(scanRoot, query) {
139
+ const memory = loadMemory(scanRoot);
140
+ if (!query || !memory.length) return memory.slice(-10);
141
+ const terms = String(query).toLowerCase().split(/\s+/).filter(Boolean);
142
+ if (!terms.length) return memory.slice(-10);
143
+ const scored = memory.map(e => {
144
+ const haystack = [e.reason, e.vuln, e.family, e.file, e.bucket].join(' ').toLowerCase();
145
+ const score = terms.reduce((s, t) => s + (haystack.includes(t) ? 1 : 0), 0);
146
+ return { ...e, score };
147
+ });
148
+ return scored.filter(e => e.score > 0).sort((a, b) => b.score - a.score).slice(0, 10);
149
+ }
150
+
151
+ export const _internals = { _bucketKey, _memPath, _agentsPath };
@@ -5,6 +5,9 @@
5
5
  import * as fs from 'node:fs';
6
6
  import * as path from 'node:path';
7
7
  import { statePath, safeWriteState } from './state-dir.js';
8
+ import { appendAcceptRiskFromTriage } from './sca-policy.js';
9
+ import { recordDecision as recordTriageMemory } from './triage-memory.js';
10
+ import { recordTriage as recordCrossRepoTriage } from './cross-repo-memory.js';
8
11
 
9
12
  export const STATES = ['open', 'in-progress', 'fixed', 'wont-fix', 'false-positive'];
10
13
 
@@ -44,6 +47,15 @@ export function syncWithScan(scanRoot, findings) {
44
47
  assignee: null,
45
48
  opened_at: now,
46
49
  comments: [],
50
+ // Phase 4 / Item 7: capture SCA-relevant fields so the
51
+ // triage โ†’ sca-policy bridge has enough data to materialize an
52
+ // accept-risk entry on wont-fix. No-ops for SAST findings.
53
+ type: f.type || null,
54
+ name: f.name || null,
55
+ version: f.version || null,
56
+ ecosystem: f.ecosystem || null,
57
+ osvId: f.osvId || null,
58
+ cveAliases: Array.isArray(f.cveAliases) ? f.cveAliases : [],
47
59
  };
48
60
  data.transitions.push({ id, from: null, to: 'open', at: now });
49
61
  }
@@ -80,7 +92,34 @@ export function transition(scanRoot, id, toState, comment) {
80
92
  if (toState === 'fixed') cur.fixed_at = new Date().toISOString();
81
93
  data.transitions.push({ id, from, to: toState, at: new Date().toISOString(), comment });
82
94
  _save(scanRoot, data);
83
- return { ok: true };
95
+
96
+ // Phase 4 / Item 7 of the SCA improvement plan โ€” bridge wont-fix
97
+ // transitions on SCA findings into sca-policy.yml accept-risk entries
98
+ // so the suppression is durable across rescans. The finding object on
99
+ // the triage store has a `type` field when it was synced from a scan
100
+ // via syncWithScan; we only bridge type === 'vulnerable_dep'.
101
+ let policyBridge = null;
102
+ if (toState === 'wont-fix' && cur.type === 'vulnerable_dep') {
103
+ try {
104
+ const reason = comment || `Marked wont-fix in triage on ${new Date().toISOString().slice(0,10)}`;
105
+ policyBridge = appendAcceptRiskFromTriage(scanRoot, cur, reason);
106
+ } catch (e) {
107
+ policyBridge = { ok: false, reason: String(e && e.message || e) };
108
+ }
109
+ }
110
+ // Triage-memory bridge โ€” write a narrative entry to AGENTS.md and a
111
+ // structured entry to triage-memory.jsonl whenever a finding is marked
112
+ // wont-fix or false-positive. The next scan's suppressByPastDecisions
113
+ // annotator demotes similar findings by bucket (family + dir).
114
+ let memoryBridge = null;
115
+ if (['wont-fix', 'false-positive'].includes(toState)) {
116
+ try { memoryBridge = recordTriageMemory(scanRoot, cur, toState, comment); }
117
+ catch (e) { memoryBridge = { ok: false, reason: String(e && e.message || e) }; }
118
+ // Also mirror into the cross-repo store so sibling repos benefit.
119
+ try { recordCrossRepoTriage({ scanRoot, finding: cur, decision: toState, reason: comment }); }
120
+ catch {}
121
+ }
122
+ return { ok: true, policyBridge, memoryBridge };
84
123
  }
85
124
 
86
125
  export function comment(scanRoot, id, author, body) {
@@ -0,0 +1,171 @@
1
+ // Watch mode โ€” continuous incremental scan as the developer edits.
2
+ //
3
+ // Spawns a long-running scan watcher that:
4
+ // 1. Subscribes to file-system events under the project root
5
+ // 2. When a file matching the scan glob changes, re-scans incrementally
6
+ // using dataflow/incremental-cache.js (per-file cache hits avoid the
7
+ // full IR build)
8
+ // 3. Diffs against the prior scan to compute a "risk delta" string
9
+ // (added/removed/changed criticals + highs)
10
+ // 4. Writes the delta to .agentic-security/watch-status.{md,json}
11
+ //
12
+ // The Claude Code statusline / a chat command can poll watch-status.md
13
+ // (cheap file read) to surface the delta inline without re-running anything.
14
+ //
15
+ // Implementation is deliberately node-only โ€” no chokidar dep, uses
16
+ // fs.promises.watch (Node โ‰ฅ 20). Debounces to 350ms.
17
+ //
18
+ // Lifecycle: start() returns an AbortController-like handle. The caller
19
+ // (a /watch slash command or a long-running daemon spawn) is responsible
20
+ // for keeping the process alive; this module is the pure logic.
21
+
22
+ import * as fs from 'node:fs/promises';
23
+ import * as fsSync from 'node:fs';
24
+ import * as path from 'node:path';
25
+
26
+ const STATE = '.agentic-security';
27
+ const STATUS_MD = 'watch-status.md';
28
+ const STATUS_JSON = 'watch-status.json';
29
+ const DEBOUNCE_MS = 350;
30
+ const MAX_BURST = 50; // ignore beyond this # of rapid events
31
+
32
+ const SCAN_EXT_RE = /\.(?:[jt]sx?|mjs|cjs|py|java|kt|go|rb|php|cs|c|cc|cpp|h|hpp|rs|sol|vy|swift|dart|toml|yml|yaml|json|tf|tfvars|bicep)$/i;
33
+ const IGNORE_DIR_RE = /(?:^|\/)(?:\.git|node_modules|\.bench-cache|dist|build|\.next|coverage|\.agentic-security)(?:$|\/)/;
34
+
35
+ function _isScanable(rel) {
36
+ if (!rel || IGNORE_DIR_RE.test(rel)) return false;
37
+ return SCAN_EXT_RE.test(rel);
38
+ }
39
+
40
+ function _readJsonSafe(fp) {
41
+ try { return JSON.parse(fsSync.readFileSync(fp, 'utf8')); } catch { return null; }
42
+ }
43
+
44
+ function _sevRank(s) { return ['info', 'low', 'medium', 'high', 'critical'].indexOf(s) + 1; }
45
+
46
+ /**
47
+ * Pure delta computation between two finding arrays. Same logic
48
+ * baseline-compare uses, surfaced as a single ASCII status line.
49
+ */
50
+ export function computeDelta(prevFindings, currFindings) {
51
+ const key = (f) => `${f.file || ''}::${f.line || 0}::${f.family || f.parser || ''}`;
52
+ const prev = new Map();
53
+ for (const f of prevFindings || []) prev.set(key(f), f);
54
+ const cur = new Map();
55
+ for (const f of currFindings || []) cur.set(key(f), f);
56
+ const added = [], removed = [];
57
+ for (const [k, f] of cur) if (!prev.has(k)) added.push(f);
58
+ for (const [k, f] of prev) if (!cur.has(k)) removed.push(f);
59
+ const newCrit = added.filter(f => f.severity === 'critical').length;
60
+ const newHigh = added.filter(f => f.severity === 'high').length;
61
+ const fixedCrit = removed.filter(f => f.severity === 'critical').length;
62
+ const fixedHigh = removed.filter(f => f.severity === 'high').length;
63
+ return {
64
+ addedCount: added.length, removedCount: removed.length,
65
+ newCritical: newCrit, newHigh, fixedCritical: fixedCrit, fixedHigh,
66
+ added, removed,
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Render a one-line status string for the Claude Code statusline.
72
+ */
73
+ export function renderStatusLine(delta) {
74
+ const parts = [];
75
+ if (delta.newCritical) parts.push(`๐Ÿ›‘ +${delta.newCritical} crit`);
76
+ if (delta.newHigh) parts.push(`โš ๏ธ +${delta.newHigh} high`);
77
+ if (delta.fixedCritical) parts.push(`โœ… -${delta.fixedCritical} crit`);
78
+ if (delta.fixedHigh) parts.push(`โœ… -${delta.fixedHigh} high`);
79
+ if (!parts.length && (delta.addedCount + delta.removedCount) === 0) return 'agentic-security: clean';
80
+ if (!parts.length) return `agentic-security: +${delta.addedCount} / -${delta.removedCount}`;
81
+ return 'agentic-security: ' + parts.join(' ยท ');
82
+ }
83
+
84
+ /**
85
+ * Persist watch-status.{md,json}. Cheap atomic write (write tmp, rename).
86
+ */
87
+ export function persistStatus(scanRoot, delta) {
88
+ const dir = path.join(scanRoot, STATE);
89
+ try { fsSync.mkdirSync(dir, { recursive: true }); } catch {}
90
+ const status = {
91
+ ts: new Date().toISOString(),
92
+ line: renderStatusLine(delta),
93
+ delta: {
94
+ addedCount: delta.addedCount, removedCount: delta.removedCount,
95
+ newCritical: delta.newCritical, newHigh: delta.newHigh,
96
+ fixedCritical: delta.fixedCritical, fixedHigh: delta.fixedHigh,
97
+ },
98
+ addedTop5: (delta.added || []).slice(0, 5).map(f => ({
99
+ file: f.file, line: f.line, family: f.family, severity: f.severity, vuln: f.vuln,
100
+ })),
101
+ };
102
+ const jsonPath = path.join(dir, STATUS_JSON);
103
+ const mdPath = path.join(dir, STATUS_MD);
104
+ try { fsSync.writeFileSync(jsonPath, JSON.stringify(status, null, 2)); } catch {}
105
+ const md = [
106
+ `# Watch status โ€” ${status.ts.slice(11, 19)} UTC`,
107
+ '',
108
+ status.line,
109
+ '',
110
+ ];
111
+ if (status.addedTop5.length) {
112
+ md.push('## New findings');
113
+ for (const f of status.addedTop5) {
114
+ md.push(`- **[${(f.severity || '?').toUpperCase()}]** ${f.vuln || f.family || 'finding'} โ€” \`${f.file}:${f.line}\``);
115
+ }
116
+ }
117
+ try { fsSync.writeFileSync(mdPath, md.join('\n')); } catch {}
118
+ return status;
119
+ }
120
+
121
+ /**
122
+ * Read the latest watch-status (returns null if none).
123
+ */
124
+ export function readStatus(scanRoot) {
125
+ return _readJsonSafe(path.join(scanRoot, STATE, STATUS_JSON));
126
+ }
127
+
128
+ /**
129
+ * Subscribe to FS events and call onChange(absPath, eventType) for each
130
+ * matching event. Debounces bursts. Returns a controller with stop().
131
+ *
132
+ * The actual incremental scan call lives in the caller โ€” this module
133
+ * stays pure for testability.
134
+ */
135
+ export async function watchProject(scanRoot, onChange, opts = {}) {
136
+ if (process.env.AGENTIC_SECURITY_NO_WATCH === '1') return { stop: async () => {}, _disabled: true };
137
+ const recursive = opts.recursive !== false;
138
+ const ac = new AbortController();
139
+ let timer = null;
140
+ const pending = new Set();
141
+ const flush = () => {
142
+ timer = null;
143
+ if (pending.size > MAX_BURST) { pending.clear(); return; }
144
+ const batch = Array.from(pending);
145
+ pending.clear();
146
+ try { onChange(batch); } catch {}
147
+ };
148
+ let stopped = false;
149
+ (async () => {
150
+ try {
151
+ for await (const evt of fs.watch(scanRoot, { recursive, signal: ac.signal })) {
152
+ if (stopped) break;
153
+ const rel = String(evt.filename || '').replace(/\\/g, '/');
154
+ if (!_isScanable(rel)) continue;
155
+ pending.add(path.join(scanRoot, rel));
156
+ if (timer) clearTimeout(timer);
157
+ timer = setTimeout(flush, DEBOUNCE_MS);
158
+ }
159
+ } catch (e) {
160
+ if (e && e.name !== 'AbortError') {
161
+ // Surface but don't crash โ€” caller decides how to handle.
162
+ try { onChange([], e); } catch {}
163
+ }
164
+ }
165
+ })();
166
+ return {
167
+ stop: async () => { stopped = true; ac.abort(); if (timer) clearTimeout(timer); },
168
+ };
169
+ }
170
+
171
+ export const _internals = { _isScanable, SCAN_EXT_RE, IGNORE_DIR_RE };