diff --git a/docs/test.ts b/docs/test.ts index 479b4620b4d..ce34a23d62b 100644 --- a/docs/test.ts +++ b/docs/test.ts @@ -111,7 +111,7 @@ tracer.init({ blockedTemplateJson: './blocked.json', blockedTemplateGraphql: './blockedgraphql.json', eventTracking: { - mode: 'safe' + mode: 'anon' }, apiSecurity: { enabled: true, diff --git a/index.d.ts b/index.d.ts index 9b4becec957..a41b4aee410 100644 --- a/index.d.ts +++ b/index.d.ts @@ -655,12 +655,24 @@ declare namespace tracer { */ eventTracking?: { /** - * Controls the automated user event tracking mode. Possible values are disabled, safe and extended. - * On safe mode, any detected Personally Identifiable Information (PII) about the user will be redacted from the event. - * On extended mode, no redaction will take place. - * @default 'safe' + * Controls the automated user tracking mode for user IDs and logins collections. Possible values: + * * 'anonymous': will hash user IDs and user logins before collecting them + * * 'anon': alias for 'anonymous' + * * 'safe': deprecated alias for 'anonymous' + * + * * 'identification': will collect user IDs and logins without redaction + * * 'ident': alias for 'identification' + * * 'extended': deprecated alias for 'identification' + * + * * 'disabled': will not collect user IDs and logins + * + * Unknown values will be considered as 'disabled' + * @default 'identification' */ - mode?: 'safe' | 'extended' | 'disabled' + mode?: + 'anonymous' | 'anon' | 'safe' | + 'identification' | 'ident' | 'extended' | + 'disabled' }, /** * Configuration for Api Security diff --git a/packages/datadog-instrumentations/src/passport-http.js b/packages/datadog-instrumentations/src/passport-http.js index 0969d2d3fc9..3b930a1a1cc 100644 --- a/packages/datadog-instrumentations/src/passport-http.js +++ b/packages/datadog-instrumentations/src/passport-http.js @@ -1,22 +1,10 @@ 'use strict' -const shimmer = require('../../datadog-shimmer') const { addHook } = require('./helpers/instrument') -const { wrapVerify } = require('./passport-utils') +const { strategyHook } = require('./passport-utils') addHook({ name: 'passport-http', file: 'lib/passport-http/strategies/basic.js', versions: ['>=0.3.0'] -}, BasicStrategy => { - return shimmer.wrapFunction(BasicStrategy, BasicStrategy => function () { - const type = 'http' - - if (typeof arguments[0] === 'function') { - arguments[0] = wrapVerify(arguments[0], false, type) - } else { - arguments[1] = wrapVerify(arguments[1], (arguments[0] && arguments[0].passReqToCallback), type) - } - return BasicStrategy.apply(this, arguments) - }) -}) +}, strategyHook) diff --git a/packages/datadog-instrumentations/src/passport-local.js b/packages/datadog-instrumentations/src/passport-local.js index dab74eb470e..c6dcec9a48d 100644 --- a/packages/datadog-instrumentations/src/passport-local.js +++ b/packages/datadog-instrumentations/src/passport-local.js @@ -1,22 +1,10 @@ 'use strict' -const shimmer = require('../../datadog-shimmer') const { addHook } = require('./helpers/instrument') -const { wrapVerify } = require('./passport-utils') +const { strategyHook } = require('./passport-utils') addHook({ name: 'passport-local', file: 'lib/strategy.js', versions: ['>=1.0.0'] -}, Strategy => { - return shimmer.wrapFunction(Strategy, Strategy => function () { - const type = 'local' - - if (typeof arguments[0] === 'function') { - arguments[0] = wrapVerify(arguments[0], false, type) - } else { - arguments[1] = wrapVerify(arguments[1], (arguments[0] && arguments[0].passReqToCallback), type) - } - return Strategy.apply(this, arguments) - }) -}) +}, strategyHook) diff --git a/packages/datadog-instrumentations/src/passport-utils.js b/packages/datadog-instrumentations/src/passport-utils.js index 7969ab486b4..de1cd090a71 100644 --- a/packages/datadog-instrumentations/src/passport-utils.js +++ b/packages/datadog-instrumentations/src/passport-utils.js @@ -5,33 +5,57 @@ const { channel } = require('./helpers/instrument') const passportVerifyChannel = channel('datadog:passport:verify:finish') -function wrapVerifiedAndPublish (username, password, verified, type) { - if (!passportVerifyChannel.hasSubscribers) { - return verified - } +function wrapVerifiedAndPublish (framework, username, verified) { + return shimmer.wrapFunction(verified, function wrapVerified (verified) { + return function wrappedVerified (err, user) { + // if there is an error, it's neither an auth success nor a failure + if (!err) { + const abortController = new AbortController() + + passportVerifyChannel.publish({ framework, login: username, user, success: !!user, abortController }) + + if (abortController.signal.aborted) return + } - // eslint-disable-next-line n/handle-callback-err - return shimmer.wrapFunction(verified, verified => function (err, user, info) { - const credentials = { type, username } - passportVerifyChannel.publish({ credentials, user }) - return verified.apply(this, arguments) + return verified.apply(this, arguments) + } }) } -function wrapVerify (verify, passReq, type) { - if (passReq) { - return function (req, username, password, verified) { - arguments[3] = wrapVerifiedAndPublish(username, password, verified, type) - return verify.apply(this, arguments) +function wrapVerify (verify) { + return function wrappedVerify (req, username, password, verified) { + if (passportVerifyChannel.hasSubscribers) { + const framework = `passport-${this.name}` + + // replace the callback with our own wrapper to get the result + if (this._passReqToCallback) { + arguments[3] = wrapVerifiedAndPublish(framework, arguments[1], arguments[3]) + } else { + arguments[2] = wrapVerifiedAndPublish(framework, arguments[0], arguments[2]) + } } - } else { - return function (username, password, verified) { - arguments[2] = wrapVerifiedAndPublish(username, password, verified, type) - return verify.apply(this, arguments) + + return verify.apply(this, arguments) + } +} + +function wrapStrategy (Strategy) { + return function wrappedStrategy () { + // verify function can be either the first or second argument + if (typeof arguments[0] === 'function') { + arguments[0] = wrapVerify(arguments[0]) + } else { + arguments[1] = wrapVerify(arguments[1]) } + + return Strategy.apply(this, arguments) } } +function strategyHook (Strategy) { + return shimmer.wrapFunction(Strategy, wrapStrategy) +} + module.exports = { - wrapVerify + strategyHook } diff --git a/packages/datadog-instrumentations/test/passport-http.spec.js b/packages/datadog-instrumentations/test/passport-http.spec.js index 2918c935e20..5cb0282ec2f 100644 --- a/packages/datadog-instrumentations/test/passport-http.spec.js +++ b/packages/datadog-instrumentations/test/passport-http.spec.js @@ -1,8 +1,9 @@ 'use strict' const agent = require('../../dd-trace/test/plugins/agent') -const axios = require('axios') +const axios = require('axios').create({ validateStatus: null }) const dc = require('dc-polyfill') +const { storage } = require('../../datadog-core') withVersions('passport-http', 'passport-http', version => { describe('passport-http instrumentation', () => { @@ -10,7 +11,7 @@ withVersions('passport-http', 'passport-http', version => { let port, server, subscriberStub before(() => { - return agent.load(['express', 'passport', 'passport-http'], { client: false }) + return agent.load(['http', 'express', 'passport', 'passport-http'], { client: false }) }) before((done) => { @@ -19,7 +20,17 @@ withVersions('passport-http', 'passport-http', version => { const BasicStrategy = require(`../../../versions/passport-http@${version}`).get().BasicStrategy const app = express() - passport.use(new BasicStrategy((username, password, done) => { + function validateUser (req, username, password, done) { + // support with or without passReqToCallback + if (typeof done !== 'function') { + done = password + password = username + username = req + } + + // simulate db error + if (username === 'error') return done('error') + const users = [{ _id: 1, username: 'test', @@ -35,7 +46,18 @@ withVersions('passport-http', 'passport-http', version => { return done(null, user) } } - )) + + passport.use('basic', new BasicStrategy({ + usernameField: 'username', + passwordField: 'password', + passReqToCallback: false + }, validateUser)) + + passport.use('basic-withreq', new BasicStrategy({ + usernameField: 'username', + passwordField: 'password', + passReqToCallback: true + }, validateUser)) app.use(passport.initialize()) app.use(express.json()) @@ -44,16 +66,14 @@ withVersions('passport-http', 'passport-http', version => { passport.authenticate('basic', { successRedirect: '/grant', failureRedirect: '/deny', - passReqToCallback: false, session: false }) ) - app.post('/req', - passport.authenticate('basic', { + app.get('/req', + passport.authenticate('basic-withreq', { successRedirect: '/grant', failureRedirect: '/deny', - passReqToCallback: true, session: false }) ) @@ -66,9 +86,7 @@ withVersions('passport-http', 'passport-http', version => { res.send('Denied') }) - passportVerifyChannel.subscribe(function ({ credentials, user, err, info }) { - subscriberStub(arguments[0]) - }) + passportVerifyChannel.subscribe((data) => subscriberStub(data)) server = app.listen(0, () => { port = server.address().port @@ -85,6 +103,18 @@ withVersions('passport-http', 'passport-http', version => { return agent.close({ ritmReset: false }) }) + it('should not call subscriber when an error occurs', async () => { + const res = await axios.get(`http://localhost:${port}/`, { + headers: { + // error:1234 + Authorization: 'Basic ZXJyb3I6MTIzNA==' + } + }) + + expect(res.status).to.equal(500) + expect(subscriberStub).to.not.be.called + }) + it('should call subscriber with proper arguments on success', async () => { const res = await axios.get(`http://localhost:${port}/`, { headers: { @@ -95,16 +125,17 @@ withVersions('passport-http', 'passport-http', version => { expect(res.status).to.equal(200) expect(res.data).to.equal('Granted') - expect(subscriberStub).to.be.calledOnceWithExactly( - { - credentials: { type: 'http', username: 'test' }, - user: { _id: 1, username: 'test', password: '1234', email: 'testuser@ddog.com' } - } - ) + expect(subscriberStub).to.be.calledOnceWithExactly({ + framework: 'passport-basic', + login: 'test', + user: { _id: 1, username: 'test', password: '1234', email: 'testuser@ddog.com' }, + success: true, + abortController: new AbortController() + }) }) it('should call subscriber with proper arguments on success with passReqToCallback set to true', async () => { - const res = await axios.get(`http://localhost:${port}/`, { + const res = await axios.get(`http://localhost:${port}/req`, { headers: { // test:1234 Authorization: 'Basic dGVzdDoxMjM0' @@ -113,12 +144,13 @@ withVersions('passport-http', 'passport-http', version => { expect(res.status).to.equal(200) expect(res.data).to.equal('Granted') - expect(subscriberStub).to.be.calledOnceWithExactly( - { - credentials: { type: 'http', username: 'test' }, - user: { _id: 1, username: 'test', password: '1234', email: 'testuser@ddog.com' } - } - ) + expect(subscriberStub).to.be.calledOnceWithExactly({ + framework: 'passport-basic', + login: 'test', + user: { _id: 1, username: 'test', password: '1234', email: 'testuser@ddog.com' }, + success: true, + abortController: new AbortController() + }) }) it('should call subscriber with proper arguments on failure', async () => { @@ -131,12 +163,37 @@ withVersions('passport-http', 'passport-http', version => { expect(res.status).to.equal(200) expect(res.data).to.equal('Denied') - expect(subscriberStub).to.be.calledOnceWithExactly( - { - credentials: { type: 'http', username: 'test' }, - user: false + expect(subscriberStub).to.be.calledOnceWithExactly({ + framework: 'passport-basic', + login: 'test', + user: false, + success: false, + abortController: new AbortController() + }) + }) + + it('should block when subscriber aborts', async () => { + subscriberStub = sinon.spy(({ abortController }) => { + storage.getStore().req.res.writeHead(403).end('Blocked') + abortController.abort() + }) + + const res = await axios.get(`http://localhost:${port}/`, { + headers: { + // test:1234 + Authorization: 'Basic dGVzdDoxMjM0' } - ) + }) + + expect(res.status).to.equal(403) + expect(res.data).to.equal('Blocked') + expect(subscriberStub).to.be.calledOnceWithExactly({ + framework: 'passport-basic', + login: 'test', + user: { _id: 1, username: 'test', password: '1234', email: 'testuser@ddog.com' }, + success: true, + abortController: new AbortController() + }) }) }) }) diff --git a/packages/datadog-instrumentations/test/passport-local.spec.js b/packages/datadog-instrumentations/test/passport-local.spec.js index d54f02b289f..bcfc2e56dc9 100644 --- a/packages/datadog-instrumentations/test/passport-local.spec.js +++ b/packages/datadog-instrumentations/test/passport-local.spec.js @@ -1,8 +1,9 @@ 'use strict' const agent = require('../../dd-trace/test/plugins/agent') -const axios = require('axios') +const axios = require('axios').create({ validateStatus: null }) const dc = require('dc-polyfill') +const { storage } = require('../../datadog-core') withVersions('passport-local', 'passport-local', version => { describe('passport-local instrumentation', () => { @@ -10,7 +11,7 @@ withVersions('passport-local', 'passport-local', version => { let port, server, subscriberStub before(() => { - return agent.load(['express', 'passport', 'passport-local'], { client: false }) + return agent.load(['http', 'express', 'passport', 'passport-local'], { client: false }) }) before((done) => { @@ -19,24 +20,44 @@ withVersions('passport-local', 'passport-local', version => { const LocalStrategy = require(`../../../versions/passport-local@${version}`).get().Strategy const app = express() - passport.use(new LocalStrategy({ usernameField: 'username', passwordField: 'password' }, - (username, password, done) => { - const users = [{ - _id: 1, - username: 'test', - password: '1234', - email: 'testuser@ddog.com' - }] - - const user = users.find(user => (user.username === username) && (user.password === password)) - - if (!user) { - return done(null, false) - } else { - return done(null, user) - } + function validateUser (req, username, password, done) { + // support with or without passReqToCallback + if (typeof done !== 'function') { + done = password + password = username + username = req } - )) + + // simulate db error + if (username === 'error') return done('error') + + const users = [{ + _id: 1, + username: 'test', + password: '1234', + email: 'testuser@ddog.com' + }] + + const user = users.find(user => (user.username === username) && (user.password === password)) + + if (!user) { + return done(null, false) + } else { + return done(null, user) + } + } + + passport.use('local', new LocalStrategy({ + usernameField: 'username', + passwordField: 'password', + passReqToCallback: false + }, validateUser)) + + passport.use('local-withreq', new LocalStrategy({ + usernameField: 'username', + passwordField: 'password', + passReqToCallback: true + }, validateUser)) app.use(passport.initialize()) app.use(express.json()) @@ -45,16 +66,14 @@ withVersions('passport-local', 'passport-local', version => { passport.authenticate('local', { successRedirect: '/grant', failureRedirect: '/deny', - passReqToCallback: false, session: false }) ) app.post('/req', - passport.authenticate('local', { + passport.authenticate('local-withreq', { successRedirect: '/grant', failureRedirect: '/deny', - passReqToCallback: true, session: false }) ) @@ -67,9 +86,7 @@ withVersions('passport-local', 'passport-local', version => { res.send('Denied') }) - passportVerifyChannel.subscribe(function ({ credentials, user, err, info }) { - subscriberStub(arguments[0]) - }) + passportVerifyChannel.subscribe((data) => subscriberStub(data)) server = app.listen(0, () => { port = server.address().port @@ -86,17 +103,25 @@ withVersions('passport-local', 'passport-local', version => { return agent.close({ ritmReset: false }) }) + it('should not call subscriber when an error occurs', async () => { + const res = await axios.post(`http://localhost:${port}/`, { username: 'error', password: '1234' }) + + expect(res.status).to.equal(500) + expect(subscriberStub).to.not.be.called + }) + it('should call subscriber with proper arguments on success', async () => { const res = await axios.post(`http://localhost:${port}/`, { username: 'test', password: '1234' }) expect(res.status).to.equal(200) expect(res.data).to.equal('Granted') - expect(subscriberStub).to.be.calledOnceWithExactly( - { - credentials: { type: 'local', username: 'test' }, - user: { _id: 1, username: 'test', password: '1234', email: 'testuser@ddog.com' } - } - ) + expect(subscriberStub).to.be.calledOnceWithExactly({ + framework: 'passport-local', + login: 'test', + user: { _id: 1, username: 'test', password: '1234', email: 'testuser@ddog.com' }, + success: true, + abortController: new AbortController() + }) }) it('should call subscriber with proper arguments on success with passReqToCallback set to true', async () => { @@ -104,12 +129,13 @@ withVersions('passport-local', 'passport-local', version => { expect(res.status).to.equal(200) expect(res.data).to.equal('Granted') - expect(subscriberStub).to.be.calledOnceWithExactly( - { - credentials: { type: 'local', username: 'test' }, - user: { _id: 1, username: 'test', password: '1234', email: 'testuser@ddog.com' } - } - ) + expect(subscriberStub).to.be.calledOnceWithExactly({ + framework: 'passport-local', + login: 'test', + user: { _id: 1, username: 'test', password: '1234', email: 'testuser@ddog.com' }, + success: true, + abortController: new AbortController() + }) }) it('should call subscriber with proper arguments on failure', async () => { @@ -117,12 +143,32 @@ withVersions('passport-local', 'passport-local', version => { expect(res.status).to.equal(200) expect(res.data).to.equal('Denied') - expect(subscriberStub).to.be.calledOnceWithExactly( - { - credentials: { type: 'local', username: 'test' }, - user: false - } - ) + expect(subscriberStub).to.be.calledOnceWithExactly({ + framework: 'passport-local', + login: 'test', + user: false, + success: false, + abortController: new AbortController() + }) + }) + + it('should block when subscriber aborts', async () => { + subscriberStub = sinon.spy(({ abortController }) => { + storage.getStore().req.res.writeHead(403).end('Blocked') + abortController.abort() + }) + + const res = await axios.post(`http://localhost:${port}/`, { username: 'test', password: '1234' }) + + expect(res.status).to.equal(403) + expect(res.data).to.equal('Blocked') + expect(subscriberStub).to.be.calledOnceWithExactly({ + framework: 'passport-local', + login: 'test', + user: { _id: 1, username: 'test', password: '1234', email: 'testuser@ddog.com' }, + success: true, + abortController: new AbortController() + }) }) }) }) diff --git a/packages/datadog-instrumentations/test/passport-utils.spec.js b/packages/datadog-instrumentations/test/passport-utils.spec.js deleted file mode 100644 index 3cf6a64a60a..00000000000 --- a/packages/datadog-instrumentations/test/passport-utils.spec.js +++ /dev/null @@ -1,36 +0,0 @@ -'use strict' - -const proxyquire = require('proxyquire') -const { channel } = require('../src/helpers/instrument') - -const passportVerifyChannel = channel('datadog:passport:verify:finish') - -describe('passport-utils', () => { - const shimmer = { - wrap: sinon.stub() - } - - let passportUtils - - beforeEach(() => { - passportUtils = proxyquire('../src/passport-utils', { - '../../datadog-shimmer': shimmer - }) - }) - - it('should not call wrap when there is no subscribers', () => { - const wrap = passportUtils.wrapVerify(() => {}, false, 'type') - - wrap() - expect(shimmer.wrap).not.to.have.been.called - }) - - it('should call wrap when there is subscribers', () => { - const wrap = passportUtils.wrapVerify(() => {}, false, 'type') - - passportVerifyChannel.subscribe(() => {}) - - wrap() - expect(shimmer.wrap).to.have.been.called - }) -}) diff --git a/packages/dd-trace/src/appsec/addresses.js b/packages/dd-trace/src/appsec/addresses.js index cb540bc4e6f..a492a5e454f 100644 --- a/packages/dd-trace/src/appsec/addresses.js +++ b/packages/dd-trace/src/appsec/addresses.js @@ -1,5 +1,6 @@ 'use strict' +// TODO: reorder all this, it's a mess module.exports = { HTTP_INCOMING_BODY: 'server.request.body', HTTP_INCOMING_QUERY: 'server.request.query', @@ -20,6 +21,8 @@ module.exports = { HTTP_CLIENT_IP: 'http.client_ip', USER_ID: 'usr.id', + USER_LOGIN: 'usr.login', + WAF_CONTEXT_PROCESSOR: 'waf.context.processor', HTTP_OUTGOING_URL: 'server.io.net.url', diff --git a/packages/dd-trace/src/appsec/index.js b/packages/dd-trace/src/appsec/index.js index d4f4adc6554..db089a61dca 100644 --- a/packages/dd-trace/src/appsec/index.js +++ b/packages/dd-trace/src/appsec/index.js @@ -28,7 +28,7 @@ const web = require('../plugins/util/web') const { extractIp } = require('../plugins/util/ip_extractor') const { HTTP_CLIENT_IP } = require('../../../../ext/tags') const { isBlocked, block, setTemplates, getBlockingAction } = require('./blocking') -const { passportTrackEvent } = require('./passport') +const UserTracking = require('./user_tracking') const { storage } = require('../../../datadog-core') const graphql = require('./graphql') const rasp = require('./rasp') @@ -59,11 +59,14 @@ function enable (_config) { apiSecuritySampler.configure(_config.appsec) + UserTracking.setCollectionMode(_config.appsec.eventTracking.mode, false) + bodyParser.subscribe(onRequestBodyParsed) multerParser.subscribe(onRequestBodyParsed) cookieParser.subscribe(onRequestCookieParser) incomingHttpRequestStart.subscribe(incomingHttpStartTranslator) incomingHttpRequestEnd.subscribe(incomingHttpEndTranslator) + passportVerify.subscribe(onPassportVerify) // possible optimization: only subscribe if collection mode is enabled queryParser.subscribe(onRequestQueryParsed) nextBodyParsed.subscribe(onRequestBodyParsed) nextQueryParsed.subscribe(onRequestQueryParsed) @@ -73,10 +76,6 @@ function enable (_config) { responseWriteHead.subscribe(onResponseWriteHead) responseSetHeader.subscribe(onResponseSetHeader) - if (_config.appsec.eventTracking.enabled) { - passportVerify.subscribe(onPassportVerify) - } - isEnabled = true config = _config } catch (err) { @@ -184,7 +183,7 @@ function incomingHttpEndTranslator ({ req, res }) { Reporter.finishRequest(req, res) } -function onPassportVerify ({ credentials, user }) { +function onPassportVerify ({ framework, login, user, success, abortController }) { const store = storage.getStore() const rootSpan = store?.req && web.root(store.req) @@ -193,7 +192,9 @@ function onPassportVerify ({ credentials, user }) { return } - passportTrackEvent(credentials, user, rootSpan, config.appsec.eventTracking.mode) + const results = UserTracking.trackLogin(framework, login, user, success, rootSpan) + + handleResults(results, store.req, store.req.res, rootSpan, abortController) } function onRequestQueryParsed ({ req, res, query, abortController }) { diff --git a/packages/dd-trace/src/appsec/passport.js b/packages/dd-trace/src/appsec/passport.js deleted file mode 100644 index 2093b7b1fdc..00000000000 --- a/packages/dd-trace/src/appsec/passport.js +++ /dev/null @@ -1,110 +0,0 @@ -'use strict' - -const log = require('../log') -const { trackEvent } = require('./sdk/track_event') -const { setUserTags } = require('./sdk/set_user') - -const UUID_PATTERN = '^[0-9A-F]{8}-[0-9A-F]{4}-[1-5][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$' -const regexUsername = new RegExp(UUID_PATTERN, 'i') - -const SDK_USER_EVENT_PATTERN = '^_dd\\.appsec\\.events\\.users\\.[\\W\\w+]+\\.sdk$' -const regexSdkEvent = new RegExp(SDK_USER_EVENT_PATTERN, 'i') - -function isSdkCalled (tags) { - let called = false - - if (tags !== null && typeof tags === 'object') { - called = Object.entries(tags).some(([key, value]) => regexSdkEvent.test(key) && value === 'true') - } - - return called -} - -// delete this function later if we know it's always credential.username -function getLogin (credentials) { - const type = credentials && credentials.type - let login - if (type === 'local' || type === 'http') { - login = credentials.username - } - - return login -} - -function parseUser (login, passportUser, mode) { - const user = { - 'usr.id': login - } - - if (!user['usr.id']) { - return user - } - - if (passportUser) { - // Guess id - if (passportUser.id) { - user['usr.id'] = passportUser.id - } else if (passportUser._id) { - user['usr.id'] = passportUser._id - } - - if (mode === 'extended') { - if (login) { - user['usr.login'] = login - } - - if (passportUser.email) { - user['usr.email'] = passportUser.email - } - - // Guess username - if (passportUser.username) { - user['usr.username'] = passportUser.username - } else if (passportUser.name) { - user['usr.username'] = passportUser.name - } - } - } - - if (mode === 'safe') { - // Remove PII in safe mode - if (!regexUsername.test(user['usr.id'])) { - user['usr.id'] = '' - } - } - - return user -} - -function passportTrackEvent (credentials, passportUser, rootSpan, mode) { - const tags = rootSpan && rootSpan.context() && rootSpan.context()._tags - - if (isSdkCalled(tags)) { - // Don't overwrite tags set by SDK callings - return - } - const user = parseUser(getLogin(credentials), passportUser, mode) - - if (user['usr.id'] === undefined) { - log.warn('No user ID found in authentication instrumentation') - return - } - - if (passportUser) { - // If a passportUser object is published then the login succeded - const userTags = {} - Object.entries(user).forEach(([k, v]) => { - const attr = k.split('.', 2)[1] - userTags[attr] = v - }) - - setUserTags(userTags, rootSpan) - trackEvent('users.login.success', null, 'passportTrackEvent', rootSpan, mode) - } else { - trackEvent('users.login.failure', user, 'passportTrackEvent', rootSpan, mode) - } -} - -module.exports = { - passportTrackEvent -} diff --git a/packages/dd-trace/src/appsec/remote_config/capabilities.js b/packages/dd-trace/src/appsec/remote_config/capabilities.js index bd729cc39cc..16034f5f9ee 100644 --- a/packages/dd-trace/src/appsec/remote_config/capabilities.js +++ b/packages/dd-trace/src/appsec/remote_config/capabilities.js @@ -22,6 +22,7 @@ module.exports = { ASM_RASP_SSRF: 1n << 23n, ASM_RASP_SHI: 1n << 24n, APM_TRACING_SAMPLE_RULES: 1n << 29n, + ASM_AUTO_USER_INSTRUM_MODE: 1n << 31n, ASM_ENDPOINT_FINGERPRINT: 1n << 32n, ASM_NETWORK_FINGERPRINT: 1n << 34n, ASM_HEADER_FINGERPRINT: 1n << 35n diff --git a/packages/dd-trace/src/appsec/remote_config/index.js b/packages/dd-trace/src/appsec/remote_config/index.js index 90cda5c6f61..7884175abb0 100644 --- a/packages/dd-trace/src/appsec/remote_config/index.js +++ b/packages/dd-trace/src/appsec/remote_config/index.js @@ -4,6 +4,8 @@ const Activation = require('../activation') const RemoteConfigManager = require('./manager') const RemoteConfigCapabilities = require('./capabilities') +const { setCollectionMode } = require('../user_tracking') +const log = require('../../log') let rc @@ -23,9 +25,31 @@ function enable (config, appsec) { rc.updateCapabilities(RemoteConfigCapabilities.ASM_ACTIVATION, true) } - rc.setProductHandler('ASM_FEATURES', (action, rcConfig) => { + rc.updateCapabilities(RemoteConfigCapabilities.ASM_AUTO_USER_INSTRUM_MODE, true) + + let autoUserInstrumModeId + + rc.setProductHandler('ASM_FEATURES', (action, rcConfig, configId) => { if (!rcConfig) return + // this is put before other handlers because it can reject the config + if (typeof rcConfig.auto_user_instrum?.mode === 'string') { + if (action === 'apply' || action === 'modify') { + // check if there is already a config applied with this field + if (autoUserInstrumModeId && configId !== autoUserInstrumModeId) { + log.error('[RC] Multiple auto_user_instrum received in ASM_FEATURES. Discarding config') + // eslint-disable-next-line no-throw-literal + throw 'Multiple auto_user_instrum.mode received in ASM_FEATURES' + } + + setCollectionMode(rcConfig.auto_user_instrum.mode) + autoUserInstrumModeId = configId + } else if (configId === autoUserInstrumModeId) { + setCollectionMode(config.appsec.eventTracking.mode) + autoUserInstrumModeId = null + } + } + if (activation === Activation.ONECLICK) { enableOrDisableAppsec(action, rcConfig, config, appsec) } diff --git a/packages/dd-trace/src/appsec/sdk/track_event.js b/packages/dd-trace/src/appsec/sdk/track_event.js index 0c1ef9c2bd9..a04f596bbc3 100644 --- a/packages/dd-trace/src/appsec/sdk/track_event.js +++ b/packages/dd-trace/src/appsec/sdk/track_event.js @@ -7,6 +7,7 @@ const standalone = require('../standalone') const waf = require('../waf') const { SAMPLING_MECHANISM_APPSEC } = require('../../constants') const { keepTrace } = require('../../priority_sampler') +const addresses = require('../addresses') function trackUserLoginSuccessEvent (tracer, user, metadata) { // TODO: better user check here and in _setUser() ? @@ -23,7 +24,13 @@ function trackUserLoginSuccessEvent (tracer, user, metadata) { setUserTags(user, rootSpan) - trackEvent('users.login.success', metadata, 'trackUserLoginSuccessEvent', rootSpan, 'sdk') + const login = user.login ?? user.id + + metadata = { 'usr.login': login, ...metadata } + + trackEvent('users.login.success', metadata, 'trackUserLoginSuccessEvent', rootSpan) + + runWaf('users.login.success', { id: user.id, login }) } function trackUserLoginFailureEvent (tracer, userId, exists, metadata) { @@ -34,11 +41,14 @@ function trackUserLoginFailureEvent (tracer, userId, exists, metadata) { const fields = { 'usr.id': userId, + 'usr.login': userId, 'usr.exists': exists ? 'true' : 'false', ...metadata } - trackEvent('users.login.failure', fields, 'trackUserLoginFailureEvent', getRootSpan(tracer), 'sdk') + trackEvent('users.login.failure', fields, 'trackUserLoginFailureEvent', getRootSpan(tracer)) + + runWaf('users.login.failure', { login: userId }) } function trackCustomEvent (tracer, eventName, metadata) { @@ -47,27 +57,18 @@ function trackCustomEvent (tracer, eventName, metadata) { return } - trackEvent(eventName, metadata, 'trackCustomEvent', getRootSpan(tracer), 'sdk') + trackEvent(eventName, metadata, 'trackCustomEvent', getRootSpan(tracer)) } -function trackEvent (eventName, fields, sdkMethodName, rootSpan, mode) { +function trackEvent (eventName, fields, sdkMethodName, rootSpan) { if (!rootSpan) { log.warn('[ASM] Root span not available in %s', sdkMethodName) return } - keepTrace(rootSpan, SAMPLING_MECHANISM_APPSEC) - const tags = { - [`appsec.events.${eventName}.track`]: 'true' - } - - if (mode === 'sdk') { - tags[`_dd.appsec.events.${eventName}.sdk`] = 'true' - } - - if (mode === 'safe' || mode === 'extended') { - tags[`_dd.appsec.events.${eventName}.auto.mode`] = mode + [`appsec.events.${eventName}.track`]: 'true', + [`_dd.appsec.events.${eventName}.sdk`]: 'true' } if (fields) { @@ -78,16 +79,28 @@ function trackEvent (eventName, fields, sdkMethodName, rootSpan, mode) { rootSpan.addTags(tags) + keepTrace(rootSpan, SAMPLING_MECHANISM_APPSEC) standalone.sample(rootSpan) +} + +function runWaf (eventName, user) { + const persistent = { + [`server.business_logic.${eventName}`]: null + } + + if (user.id) { + persistent[addresses.USER_ID] = '' + user.id + } - if (['users.login.success', 'users.login.failure'].includes(eventName)) { - waf.run({ persistent: { [`server.business_logic.${eventName}`]: null } }) + if (user.login) { + persistent[addresses.USER_LOGIN] = '' + user.login } + + waf.run({ persistent }) } module.exports = { trackUserLoginSuccessEvent, trackUserLoginFailureEvent, - trackCustomEvent, - trackEvent + trackCustomEvent } diff --git a/packages/dd-trace/src/appsec/telemetry.js b/packages/dd-trace/src/appsec/telemetry.js index d96ca77601f..8e9a2518f80 100644 --- a/packages/dd-trace/src/appsec/telemetry.js +++ b/packages/dd-trace/src/appsec/telemetry.js @@ -172,6 +172,15 @@ function addRaspRequestMetrics (store, { duration, durationExt }) { store[DD_TELEMETRY_REQUEST_METRICS].raspEvalCount++ } +function incrementMissingUserLoginMetric (framework, eventType) { + if (!enabled) return + + appsecMetrics.count('instrum.user_auth.missing_user_login', { + framework, + event_type: eventType + }).inc() +} + function getRequestMetrics (req) { if (req) { const store = getStore(req) @@ -188,6 +197,7 @@ module.exports = { incrementWafInitMetric, incrementWafUpdatesMetric, incrementWafRequestsMetric, + incrementMissingUserLoginMetric, getRequestMetrics } diff --git a/packages/dd-trace/src/appsec/user_tracking.js b/packages/dd-trace/src/appsec/user_tracking.js new file mode 100644 index 00000000000..5b92f80d642 --- /dev/null +++ b/packages/dd-trace/src/appsec/user_tracking.js @@ -0,0 +1,168 @@ +'use strict' + +const crypto = require('crypto') +const log = require('../log') +const telemetry = require('./telemetry') +const addresses = require('./addresses') +const { keepTrace } = require('../priority_sampler') +const { SAMPLING_MECHANISM_APPSEC } = require('../constants') +const standalone = require('./standalone') +const waf = require('./waf') + +// the RFC doesn't include '_id', but it's common in MongoDB +const USER_ID_FIELDS = ['id', '_id', 'email', 'username', 'login', 'user'] + +let collectionMode + +function setCollectionMode (mode, overwrite = true) { + // don't overwrite if already set, only used in appsec/index.js to not overwrite RC values + if (!overwrite && collectionMode) return + + /* eslint-disable no-fallthrough */ + switch (mode) { + case 'safe': + log.warn('[ASM] Using deprecated value "safe" in config.appsec.eventTracking.mode') + case 'anon': + case 'anonymization': + collectionMode = 'anonymization' + break + + case 'extended': + log.warn('[ASM] Using deprecated value "extended" in config.appsec.eventTracking.mode') + case 'ident': + case 'identification': + collectionMode = 'identification' + break + + default: + collectionMode = 'disabled' + } + /* eslint-enable no-fallthrough */ +} + +function obfuscateIfNeeded (str) { + if (collectionMode === 'anonymization') { + // get first 16 bytes of sha256 hash in lowercase hex + return 'anon_' + crypto.createHash('sha256').update(str).digest().toString('hex', 0, 16).toLowerCase() + } else { + return str + } +} + +// TODO: should we find other ways to get the user ID ? +function getUserId (user) { + if (!user) return + + for (const field of USER_ID_FIELDS) { + let id = user[field] + + // try to find a field that can be stringified + if (id && typeof id.toString === 'function') { + id = id.toString() + + if (typeof id !== 'string' || id.startsWith('[object ')) { + // probably not a usable ID ? + continue + } + + return obfuscateIfNeeded(id) + } + } +} + +function trackLogin (framework, login, user, success, rootSpan) { + if (!collectionMode || collectionMode === 'disabled') return + + if (!rootSpan) { + log.error('[ASM] No rootSpan found in AppSec trackLogin') + return + } + + if (typeof login !== 'string') { + log.error('[ASM] Invalid login provided to AppSec trackLogin') + + telemetry.incrementMissingUserLoginMetric(framework, success ? 'login_success' : 'login_failure') + // note: + // if we start supporting using userId if login is missing, we need to only give up if both are missing, and + // implement 'appsec.instrum.user_auth.missing_user_id' telemetry too + return + } + + login = obfuscateIfNeeded(login) + const userId = getUserId(user) + + let newTags + + const persistent = { + [addresses.USER_LOGIN]: login + } + + const currentTags = rootSpan.context()._tags + const isSdkCalled = currentTags[`_dd.appsec.events.users.login.${success ? 'success' : 'failure'}.sdk`] === 'true' + + // used to not overwrite tags set by SDK + function shouldSetTag (tag) { + return !(isSdkCalled && currentTags[tag]) + } + + if (success) { + newTags = { + 'appsec.events.users.login.success.track': 'true', + '_dd.appsec.events.users.login.success.auto.mode': collectionMode, + '_dd.appsec.usr.login': login + } + + if (shouldSetTag('appsec.events.users.login.success.usr.login')) { + newTags['appsec.events.users.login.success.usr.login'] = login + } + + if (userId) { + newTags['_dd.appsec.usr.id'] = userId + + if (shouldSetTag('usr.id')) { + newTags['usr.id'] = userId + persistent[addresses.USER_ID] = userId + } + } + + persistent[addresses.LOGIN_SUCCESS] = null + } else { + newTags = { + 'appsec.events.users.login.failure.track': 'true', + '_dd.appsec.events.users.login.failure.auto.mode': collectionMode, + '_dd.appsec.usr.login': login + } + + if (shouldSetTag('appsec.events.users.login.failure.usr.login')) { + newTags['appsec.events.users.login.failure.usr.login'] = login + } + + if (userId) { + newTags['_dd.appsec.usr.id'] = userId + + if (shouldSetTag('appsec.events.users.login.failure.usr.id')) { + newTags['appsec.events.users.login.failure.usr.id'] = userId + } + } + + /* TODO: if one day we have this info + if (exists != null && shouldSetTag('appsec.events.users.login.failure.usr.exists')) { + newTags['appsec.events.users.login.failure.usr.exists'] = exists + } + */ + + persistent[addresses.LOGIN_FAILURE] = null + } + + keepTrace(rootSpan, SAMPLING_MECHANISM_APPSEC) + standalone.sample(rootSpan) + + rootSpan.addTags(newTags) + + return waf.run({ persistent }) +} + +module.exports = { + setCollectionMode, + trackLogin +} diff --git a/packages/dd-trace/src/config.js b/packages/dd-trace/src/config.js index 808704bd7e4..6f630212799 100644 --- a/packages/dd-trace/src/config.js +++ b/packages/dd-trace/src/config.js @@ -449,8 +449,7 @@ class Config { this._setValue(defaults, 'appsec.blockedTemplateHtml', undefined) this._setValue(defaults, 'appsec.blockedTemplateJson', undefined) this._setValue(defaults, 'appsec.enabled', undefined) - this._setValue(defaults, 'appsec.eventTracking.enabled', true) - this._setValue(defaults, 'appsec.eventTracking.mode', 'safe') + this._setValue(defaults, 'appsec.eventTracking.mode', 'identification') this._setValue(defaults, 'appsec.obfuscatorKeyRegex', defaultWafObfuscatorKeyRegex) this._setValue(defaults, 'appsec.obfuscatorValueRegex', defaultWafObfuscatorValueRegex) this._setValue(defaults, 'appsec.rasp.enabled', true) @@ -574,6 +573,7 @@ class Config { DD_AGENT_HOST, DD_API_SECURITY_ENABLED, DD_API_SECURITY_SAMPLE_DELAY, + DD_APPSEC_AUTO_USER_INSTRUMENTATION_MODE, DD_APPSEC_AUTOMATED_USER_EVENTS_TRACKING, DD_APPSEC_ENABLED, DD_APPSEC_GRAPHQL_BLOCKED_TEMPLATE_JSON, @@ -712,11 +712,10 @@ class Config { this._setValue(env, 'appsec.blockedTemplateJson', maybeFile(DD_APPSEC_HTTP_BLOCKED_TEMPLATE_JSON)) this._envUnprocessed['appsec.blockedTemplateJson'] = DD_APPSEC_HTTP_BLOCKED_TEMPLATE_JSON this._setBoolean(env, 'appsec.enabled', DD_APPSEC_ENABLED) - if (DD_APPSEC_AUTOMATED_USER_EVENTS_TRACKING) { - this._setValue(env, 'appsec.eventTracking.enabled', - ['extended', 'safe'].includes(DD_APPSEC_AUTOMATED_USER_EVENTS_TRACKING.toLowerCase())) - this._setValue(env, 'appsec.eventTracking.mode', DD_APPSEC_AUTOMATED_USER_EVENTS_TRACKING.toLowerCase()) - } + this._setString(env, 'appsec.eventTracking.mode', coalesce( + DD_APPSEC_AUTO_USER_INSTRUMENTATION_MODE, + DD_APPSEC_AUTOMATED_USER_EVENTS_TRACKING // TODO: remove in next major + )) this._setString(env, 'appsec.obfuscatorKeyRegex', DD_APPSEC_OBFUSCATION_PARAMETER_KEY_REGEXP) this._setString(env, 'appsec.obfuscatorValueRegex', DD_APPSEC_OBFUSCATION_PARAMETER_VALUE_REGEXP) this._setBoolean(env, 'appsec.rasp.enabled', DD_APPSEC_RASP_ENABLED) @@ -895,12 +894,7 @@ class Config { this._setValue(opts, 'appsec.blockedTemplateJson', maybeFile(options.appsec.blockedTemplateJson)) this._optsUnprocessed['appsec.blockedTemplateJson'] = options.appsec.blockedTemplateJson this._setBoolean(opts, 'appsec.enabled', options.appsec.enabled) - let eventTracking = options.appsec.eventTracking?.mode - if (eventTracking) { - eventTracking = eventTracking.toLowerCase() - this._setValue(opts, 'appsec.eventTracking.enabled', ['extended', 'safe'].includes(eventTracking)) - this._setValue(opts, 'appsec.eventTracking.mode', eventTracking) - } + this._setString(opts, 'appsec.eventTracking.mode', options.appsec.eventTracking?.mode) this._setString(opts, 'appsec.obfuscatorKeyRegex', options.appsec.obfuscatorKeyRegex) this._setString(opts, 'appsec.obfuscatorValueRegex', options.appsec.obfuscatorValueRegex) this._setBoolean(opts, 'appsec.rasp.enabled', options.appsec.rasp?.enabled) diff --git a/packages/dd-trace/test/appsec/index.spec.js b/packages/dd-trace/test/appsec/index.spec.js index 4ec92f7b0e6..7ca54e9241b 100644 --- a/packages/dd-trace/test/appsec/index.spec.js +++ b/packages/dd-trace/test/appsec/index.spec.js @@ -44,7 +44,7 @@ describe('AppSec Index', function () { let AppSec let web let blocking - let passport + let UserTracking let log let appsecTelemetry let graphql @@ -65,8 +65,7 @@ describe('AppSec Index', function () { blockedTemplateHtml: blockedTemplate.html, blockedTemplateJson: blockedTemplate.json, eventTracking: { - enabled: true, - mode: 'safe' + mode: 'anon' }, apiSecurity: { enabled: false, @@ -90,8 +89,9 @@ describe('AppSec Index', function () { setTemplates: sinon.stub() } - passport = { - passportTrackEvent: sinon.stub() + UserTracking = { + setCollectionMode: sinon.stub(), + trackLogin: sinon.stub() } log = { @@ -124,7 +124,7 @@ describe('AppSec Index', function () { '../log': log, '../plugins/util/web': web, './blocking': blocking, - './passport': passport, + './user_tracking': UserTracking, './telemetry': appsecTelemetry, './graphql': graphql, './api_security_sampler': apiSecuritySampler, @@ -152,6 +152,7 @@ describe('AppSec Index', function () { expect(blocking.setTemplates).to.have.been.calledOnceWithExactly(config) expect(RuleManager.loadRules).to.have.been.calledOnceWithExactly(config.appsec) expect(Reporter.setRateLimit).to.have.been.calledOnceWithExactly(42) + expect(UserTracking.setCollectionMode).to.have.been.calledOnceWithExactly('anon', false) expect(incomingHttpRequestStart.subscribe) .to.have.been.calledOnceWithExactly(AppSec.incomingHttpStartTranslator) expect(incomingHttpRequestEnd.subscribe).to.have.been.calledOnceWithExactly(AppSec.incomingHttpEndTranslator) @@ -197,13 +198,13 @@ describe('AppSec Index', function () { expect(responseSetHeader.hasSubscribers).to.be.true }) - it('should not subscribe to passportVerify if eventTracking is disabled', () => { - config.appsec.eventTracking.enabled = false + it('should still subscribe to passportVerify if eventTracking is disabled', () => { + config.appsec.eventTracking.mode = 'disabled' AppSec.disable() AppSec.enable(config) - expect(passportVerify.hasSubscribers).to.be.false + expect(passportVerify.hasSubscribers).to.be.true }) it('should call appsec telemetry enable', () => { @@ -365,7 +366,7 @@ describe('AppSec Index', function () { const res = { getHeaders: () => ({ 'content-type': 'application/json', - 'content-lenght': 42 + 'content-length': 42 }), statusCode: 201 } @@ -403,7 +404,7 @@ describe('AppSec Index', function () { const res = { getHeaders: () => ({ 'content-type': 'application/json', - 'content-lenght': 42 + 'content-length': 42 }), statusCode: 201 } @@ -449,7 +450,7 @@ describe('AppSec Index', function () { const res = { getHeaders: () => ({ 'content-type': 'application/json', - 'content-lenght': 42 + 'content-length': 42 }), statusCode: 201 } @@ -515,7 +516,7 @@ describe('AppSec Index', function () { const res = { getHeaders: () => ({ 'content-type': 'application/json', - 'content-lenght': 42 + 'content-length': 42 }), statusCode: 201 } @@ -561,7 +562,7 @@ describe('AppSec Index', function () { const res = { getHeaders: () => ({ 'content-type': 'application/json', - 'content-lenght': 42 + 'content-length': 42 }), statusCode: 201 } @@ -649,6 +650,17 @@ describe('AppSec Index', function () { abortController = { abort: sinon.stub() } + res = { + getHeaders: () => ({ + 'content-type': 'application/json', + 'content-length': 42 + }), + writeHead: sinon.stub(), + end: sinon.stub(), + getHeaderNames: sinon.stub().returns([]) + } + res.writeHead.returns(res) + req = { url: '/path', headers: { @@ -659,18 +671,9 @@ describe('AppSec Index', function () { socket: { remoteAddress: '127.0.0.1', remotePort: 8080 - } - } - res = { - getHeaders: () => ({ - 'content-type': 'application/json', - 'content-lenght': 42 - }), - writeHead: sinon.stub(), - end: sinon.stub(), - getHeaderNames: sinon.stub().returns([]) + }, + res } - res.writeHead.returns(res) AppSec.enable(config) AppSec.incomingHttpStartTranslator({ req, res }) @@ -807,31 +810,84 @@ describe('AppSec Index', function () { }) describe('onPassportVerify', () => { - it('Should call passportTrackEvent', () => { - const credentials = { type: 'local', username: 'test' } - const user = { id: '1234', username: 'Test' } + beforeEach(() => { + web.root.resetHistory() + sinon.stub(storage, 'getStore').returns({ req }) + }) - sinon.stub(storage, 'getStore').returns({ req: {} }) + it('should block when UserTracking.login() returns action', () => { + UserTracking.trackLogin.returns(resultActions) - passportVerify.publish({ credentials, user }) + const abortController = new AbortController() + const payload = { + framework: 'passport-local', + login: 'test', + user: { _id: 1, username: 'test', password: '1234' }, + success: true, + abortController + } + + passportVerify.publish(payload) + + expect(storage.getStore).to.have.been.calledOnce + expect(web.root).to.have.been.calledOnceWithExactly(req) + expect(UserTracking.trackLogin).to.have.been.calledOnceWithExactly( + payload.framework, + payload.login, + payload.user, + payload.success, + rootSpan + ) + expect(abortController.signal.aborted).to.be.true + expect(res.end).to.have.been.called + }) - expect(passport.passportTrackEvent).to.have.been.calledOnceWithExactly( - credentials, - user, - rootSpan, - config.appsec.eventTracking.mode) + it('should not block when UserTracking.login() returns nothing', () => { + UserTracking.trackLogin.returns(undefined) + + const abortController = new AbortController() + const payload = { + framework: 'passport-local', + login: 'test', + user: { _id: 1, username: 'test', password: '1234' }, + success: true, + abortController + } + + passportVerify.publish(payload) + + expect(storage.getStore).to.have.been.calledOnce + expect(web.root).to.have.been.calledOnceWithExactly(req) + expect(UserTracking.trackLogin).to.have.been.calledOnceWithExactly( + payload.framework, + payload.login, + payload.user, + payload.success, + rootSpan + ) + expect(abortController.signal.aborted).to.be.false + expect(res.end).to.not.have.been.called }) - it('Should call log if no rootSpan is found', () => { - const credentials = { type: 'local', username: 'test' } - const user = { id: '1234', username: 'Test' } + it('should not block and call log if no rootSpan is found', () => { + storage.getStore.returns(undefined) - sinon.stub(storage, 'getStore').returns(undefined) + const abortController = new AbortController() + const payload = { + framework: 'passport-local', + login: 'test', + user: { _id: 1, username: 'test', password: '1234' }, + success: true, + abortController + } - passportVerify.publish({ credentials, user }) + passportVerify.publish(payload) + expect(storage.getStore).to.have.been.calledOnce expect(log.warn).to.have.been.calledOnceWithExactly('[ASM] No rootSpan found in onPassportVerify') - expect(passport.passportTrackEvent).not.to.have.been.called + expect(UserTracking.trackLogin).to.not.have.been.called + expect(abortController.signal.aborted).to.be.false + expect(res.end).to.not.have.been.called }) }) @@ -841,7 +897,7 @@ describe('AppSec Index', function () { const responseHeaders = { 'content-type': 'application/json', - 'content-lenght': 42, + 'content-length': 42, 'set-cookie': 'a=1;b=2' } @@ -852,7 +908,7 @@ describe('AppSec Index', function () { 'server.response.status': '404', 'server.response.headers.no_cookies': { 'content-type': 'application/json', - 'content-lenght': 42 + 'content-length': 42 } } }, req) @@ -873,7 +929,7 @@ describe('AppSec Index', function () { const responseHeaders = { 'content-type': 'application/json', - 'content-lenght': 42, + 'content-length': 42, 'set-cookie': 'a=1;b=2' } @@ -884,7 +940,7 @@ describe('AppSec Index', function () { 'server.response.status': '404', 'server.response.headers.no_cookies': { 'content-type': 'application/json', - 'content-lenght': 42 + 'content-length': 42 } } }, req) @@ -904,7 +960,7 @@ describe('AppSec Index', function () { const responseHeaders = { 'content-type': 'application/json', - 'content-lenght': 42, + 'content-length': 42, 'set-cookie': 'a=1;b=2' } @@ -920,7 +976,7 @@ describe('AppSec Index', function () { const responseHeaders = { 'content-type': 'application/json', - 'content-lenght': 42, + 'content-length': 42, 'set-cookie': 'a=1;b=2' } @@ -931,7 +987,7 @@ describe('AppSec Index', function () { 'server.response.status': '404', 'server.response.headers.no_cookies': { 'content-type': 'application/json', - 'content-lenght': 42 + 'content-length': 42 } } }, req) @@ -947,7 +1003,7 @@ describe('AppSec Index', function () { const responseHeaders = { 'content-type': 'application/json', - 'content-lenght': 42, + 'content-length': 42, 'set-cookie': 'a=1;b=2' } responseWriteHead.publish({ req, res, abortController, statusCode: 404, responseHeaders }) diff --git a/packages/dd-trace/test/appsec/passport.spec.js b/packages/dd-trace/test/appsec/passport.spec.js deleted file mode 100644 index 7a3db36798c..00000000000 --- a/packages/dd-trace/test/appsec/passport.spec.js +++ /dev/null @@ -1,245 +0,0 @@ -'use strict' - -const proxyquire = require('proxyquire') - -describe('Passport', () => { - const rootSpan = { - context: () => { return {} } - } - const loginLocal = { type: 'local', username: 'test' } - const userUuid = { - id: '591dc126-8431-4d0f-9509-b23318d3dce4', - email: 'testUser@test.com', - username: 'Test User' - } - - let passportModule, log, events, setUser - - beforeEach(() => { - rootSpan.context = () => { return {} } - - log = { - warn: sinon.stub() - } - - events = { - trackEvent: sinon.stub() - } - - setUser = { - setUserTags: sinon.stub() - } - - passportModule = proxyquire('../../src/appsec/passport', { - '../log': log, - './sdk/track_event': events, - './sdk/set_user': setUser - }) - }) - - describe('passportTrackEvent', () => { - it('should call log when credentials is undefined', () => { - passportModule.passportTrackEvent(undefined, undefined, undefined, 'safe') - - expect(log.warn).to.have.been.calledOnceWithExactly('No user ID found in authentication instrumentation') - }) - - it('should call log when type is not known', () => { - const credentials = { type: 'unknown', username: 'test' } - - passportModule.passportTrackEvent(credentials, undefined, undefined, 'safe') - - expect(log.warn).to.have.been.calledOnceWithExactly('No user ID found in authentication instrumentation') - }) - - it('should call log when type is known but username not present', () => { - const credentials = { type: 'http' } - - passportModule.passportTrackEvent(credentials, undefined, undefined, 'safe') - - expect(log.warn).to.have.been.calledOnceWithExactly('No user ID found in authentication instrumentation') - }) - - it('should report login failure when passportUser is not present', () => { - passportModule.passportTrackEvent(loginLocal, undefined, undefined, 'safe') - - expect(setUser.setUserTags).not.to.have.been.called - expect(events.trackEvent).to.have.been.calledOnceWithExactly( - 'users.login.failure', - { 'usr.id': '' }, - 'passportTrackEvent', - undefined, - 'safe' - ) - }) - - it('should report login success when passportUser is present', () => { - passportModule.passportTrackEvent(loginLocal, userUuid, rootSpan, 'safe') - - expect(setUser.setUserTags).to.have.been.calledOnceWithExactly({ id: userUuid.id }, rootSpan) - expect(events.trackEvent).to.have.been.calledOnceWithExactly( - 'users.login.success', - null, - 'passportTrackEvent', - rootSpan, - 'safe' - ) - }) - - it('should report login success and blank id in safe mode when id is not a uuid', () => { - const user = { - id: 'publicName', - email: 'testUser@test.com', - username: 'Test User' - } - passportModule.passportTrackEvent(loginLocal, user, rootSpan, 'safe') - - expect(setUser.setUserTags).to.have.been.calledOnceWithExactly({ id: '' }, rootSpan) - expect(events.trackEvent).to.have.been.calledOnceWithExactly( - 'users.login.success', - null, - 'passportTrackEvent', - rootSpan, - 'safe' - ) - }) - - it('should report login success and the extended fields in extended mode', () => { - const user = { - id: 'publicName', - email: 'testUser@test.com', - username: 'Test User' - } - - passportModule.passportTrackEvent(loginLocal, user, rootSpan, 'extended') - expect(setUser.setUserTags).to.have.been.calledOnceWithExactly( - { - id: 'publicName', - login: 'test', - email: 'testUser@test.com', - username: 'Test User' - }, - rootSpan - ) - expect(events.trackEvent).to.have.been.calledOnceWithExactly( - 'users.login.success', - null, - 'passportTrackEvent', - rootSpan, - 'extended' - ) - }) - - it('should not call trackEvent in safe mode if sdk user event functions are already called', () => { - rootSpan.context = () => { - return { - _tags: { - '_dd.appsec.events.users.login.success.sdk': 'true' - } - } - } - - passportModule.passportTrackEvent(loginLocal, userUuid, rootSpan, 'safe') - expect(setUser.setUserTags).not.to.have.been.called - expect(events.trackEvent).not.to.have.been.called - }) - - it('should not call trackEvent in extended mode if trackUserLoginSuccessEvent is already called', () => { - rootSpan.context = () => { - return { - _tags: { - '_dd.appsec.events.users.login.success.sdk': 'true' - } - } - } - - passportModule.passportTrackEvent(loginLocal, userUuid, rootSpan, 'extended') - expect(setUser.setUserTags).not.to.have.been.called - expect(events.trackEvent).not.to.have.been.called - }) - - it('should call trackEvent in extended mode if trackCustomEvent function is already called', () => { - rootSpan.context = () => { - return { - _tags: { - '_dd.appsec.events.custom.event.sdk': 'true' - } - } - } - - passportModule.passportTrackEvent(loginLocal, userUuid, rootSpan, 'extended') - expect(events.trackEvent).to.have.been.calledOnceWithExactly( - 'users.login.success', - null, - 'passportTrackEvent', - rootSpan, - 'extended' - ) - }) - - it('should not call trackEvent in extended mode if trackUserLoginFailureEvent is already called', () => { - rootSpan.context = () => { - return { - _tags: { - '_dd.appsec.events.users.login.failure.sdk': 'true' - } - } - } - - passportModule.passportTrackEvent(loginLocal, userUuid, rootSpan, 'extended') - expect(setUser.setUserTags).not.to.have.been.called - expect(events.trackEvent).not.to.have.been.called - }) - - it('should report login success with the _id field', () => { - const user = { - _id: '591dc126-8431-4d0f-9509-b23318d3dce4', - email: 'testUser@test.com', - username: 'Test User' - } - - passportModule.passportTrackEvent(loginLocal, user, rootSpan, 'extended') - expect(setUser.setUserTags).to.have.been.calledOnceWithExactly( - { - id: '591dc126-8431-4d0f-9509-b23318d3dce4', - login: 'test', - email: 'testUser@test.com', - username: 'Test User' - }, - rootSpan - ) - expect(events.trackEvent).to.have.been.calledOnceWithExactly( - 'users.login.success', - null, - 'passportTrackEvent', - rootSpan, - 'extended' - ) - }) - - it('should report login success with the username field passport name', () => { - const user = { - email: 'testUser@test.com', - name: 'Test User' - } - - rootSpan.context = () => { return {} } - - passportModule.passportTrackEvent(loginLocal, user, rootSpan, 'extended') - expect(setUser.setUserTags).to.have.been.calledOnceWithExactly( - { - id: 'test', - login: 'test', - email: 'testUser@test.com', - username: 'Test User' - }, rootSpan) - expect(events.trackEvent).to.have.been.calledOnceWithExactly( - 'users.login.success', - null, - 'passportTrackEvent', - rootSpan, - 'extended' - ) - }) - }) -}) diff --git a/packages/dd-trace/test/appsec/remote_config/index.spec.js b/packages/dd-trace/test/appsec/remote_config/index.spec.js index 67447cf7a69..f3cc6a32dac 100644 --- a/packages/dd-trace/test/appsec/remote_config/index.spec.js +++ b/packages/dd-trace/test/appsec/remote_config/index.spec.js @@ -7,6 +7,8 @@ let config let rc let RemoteConfigManager let RuleManager +let UserTracking +let log let appsec let remoteConfig @@ -14,7 +16,10 @@ describe('Remote Config index', () => { beforeEach(() => { config = { appsec: { - enabled: undefined + enabled: undefined, + eventTracking: { + mode: 'identification' + } } } @@ -32,6 +37,14 @@ describe('Remote Config index', () => { updateWafFromRC: sinon.stub() } + UserTracking = { + setCollectionMode: sinon.stub() + } + + log = { + error: sinon.stub() + } + appsec = { enable: sinon.spy(), disable: sinon.spy() @@ -40,40 +53,48 @@ describe('Remote Config index', () => { remoteConfig = proxyquire('../src/appsec/remote_config', { './manager': RemoteConfigManager, '../rule_manager': RuleManager, + '../user_tracking': UserTracking, + '../../log': log, '..': appsec }) }) describe('enable', () => { it('should listen to remote config when appsec is not explicitly configured', () => { - config.appsec = { enabled: undefined } + config.appsec.enabled = undefined remoteConfig.enable(config) expect(RemoteConfigManager).to.have.been.calledOnceWithExactly(config) expect(rc.updateCapabilities).to.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_ACTIVATION, true) + expect(rc.updateCapabilities) + .to.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_AUTO_USER_INSTRUM_MODE, true) expect(rc.setProductHandler).to.have.been.calledWith('ASM_FEATURES') expect(rc.setProductHandler.firstCall.args[1]).to.be.a('function') }) it('should listen to remote config when appsec is explicitly configured as enabled=true', () => { - config.appsec = { enabled: true } + config.appsec.enabled = true remoteConfig.enable(config) expect(RemoteConfigManager).to.have.been.calledOnceWithExactly(config) - expect(rc.updateCapabilities).to.not.have.been.calledWith('ASM_ACTIVATION') + expect(rc.updateCapabilities).to.not.have.been.calledWith(RemoteConfigCapabilities.ASM_ACTIVATION) + expect(rc.updateCapabilities) + .to.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_AUTO_USER_INSTRUM_MODE, true) expect(rc.setProductHandler).to.have.been.calledOnceWith('ASM_FEATURES') expect(rc.setProductHandler.firstCall.args[1]).to.be.a('function') }) it('should not listen to remote config when appsec is explicitly configured as enabled=false', () => { - config.appsec = { enabled: false } + config.appsec.enabled = false remoteConfig.enable(config) expect(RemoteConfigManager).to.have.been.calledOnceWithExactly(config) expect(rc.updateCapabilities).to.not.have.been.calledWith(RemoteConfigCapabilities.ASM_ACTIVATION, true) + expect(rc.updateCapabilities) + .to.not.have.been.calledWith(RemoteConfigCapabilities.ASM_AUTO_USER_INSTRUM_MODE, true) expect(rc.setProductHandler).to.not.have.been.called }) @@ -81,8 +102,6 @@ describe('Remote Config index', () => { let listener beforeEach(() => { - config.appsec = { enabled: undefined } - remoteConfig.enable(config, appsec) listener = rc.setProductHandler.firstCall.args[1] @@ -100,8 +119,8 @@ describe('Remote Config index', () => { expect(appsec.enable).to.have.been.called }) - it('should disable appsec when listener is called with unnaply and enabled', () => { - listener('unnaply', { asm: { enabled: true } }) + it('should disable appsec when listener is called with unapply and enabled', () => { + listener('unapply', { asm: { enabled: true } }) expect(appsec.disable).to.have.been.calledOnce }) @@ -112,6 +131,60 @@ describe('Remote Config index', () => { expect(appsec.enable).to.not.have.been.called expect(appsec.disable).to.not.have.been.called }) + + describe('auto_user_instrum', () => { + const rcConfig = { auto_user_instrum: { mode: 'anonymous' } } + const configId = 'collectionModeId' + + afterEach(() => { + listener('unapply', rcConfig, configId) + }) + + it('should not update collection mode when not a string', () => { + listener('apply', { auto_user_instrum: { mode: 123 } }, configId) + + expect(UserTracking.setCollectionMode).to.not.have.been.called + }) + + it('should throw when called two times with different config ids', () => { + listener('apply', rcConfig, configId) + + expect(() => listener('apply', rcConfig, 'anotherId')).to.throw() + expect(log.error).to.have.been.calledOnceWithExactly( + '[RC] Multiple auto_user_instrum received in ASM_FEATURES. Discarding config' + ) + }) + + it('should update collection mode when called with apply', () => { + listener('apply', rcConfig, configId) + + expect(UserTracking.setCollectionMode).to.have.been.calledOnceWithExactly(rcConfig.auto_user_instrum.mode) + }) + + it('should update collection mode when called with modify', () => { + listener('modify', rcConfig, configId) + + expect(UserTracking.setCollectionMode).to.have.been.calledOnceWithExactly(rcConfig.auto_user_instrum.mode) + }) + + it('should revert collection mode when called with unapply', () => { + listener('apply', rcConfig, configId) + UserTracking.setCollectionMode.resetHistory() + + listener('unapply', rcConfig, configId) + + expect(UserTracking.setCollectionMode).to.have.been.calledOnceWithExactly(config.appsec.eventTracking.mode) + }) + + it('should not revert collection mode when called with unapply and unknown id', () => { + listener('apply', rcConfig, configId) + UserTracking.setCollectionMode.resetHistory() + + listener('unapply', rcConfig, 'unknownId') + + expect(UserTracking.setCollectionMode).to.not.have.been.called + }) + }) }) }) diff --git a/packages/dd-trace/test/appsec/sdk/track_event.spec.js b/packages/dd-trace/test/appsec/sdk/track_event.spec.js index 97f1ac07bd7..8e3c1a177bd 100644 --- a/packages/dd-trace/test/appsec/sdk/track_event.spec.js +++ b/packages/dd-trace/test/appsec/sdk/track_event.spec.js @@ -4,7 +4,7 @@ const proxyquire = require('proxyquire') const agent = require('../../plugins/agent') const axios = require('axios') const tracer = require('../../../../../index') -const { LOGIN_SUCCESS, LOGIN_FAILURE } = require('../../../src/appsec/addresses') +const { LOGIN_SUCCESS, LOGIN_FAILURE, USER_ID, USER_LOGIN } = require('../../../src/appsec/addresses') const { SAMPLING_MECHANISM_APPSEC } = require('../../../src/constants') const { USER_KEEP } = require('../../../../../ext/priority') @@ -12,13 +12,13 @@ describe('track_event', () => { describe('Internal API', () => { const tracer = {} let log + let prioritySampler let rootSpan let getRootSpan let setUserTags - let trackUserLoginSuccessEvent, trackUserLoginFailureEvent, trackCustomEvent, trackEvent let sample let waf - let prioritySampler + let trackUserLoginSuccessEvent, trackUserLoginFailureEvent, trackCustomEvent beforeEach(() => { log = { @@ -62,11 +62,6 @@ describe('track_event', () => { trackUserLoginSuccessEvent = trackEvents.trackUserLoginSuccessEvent trackUserLoginFailureEvent = trackEvents.trackUserLoginFailureEvent trackCustomEvent = trackEvents.trackCustomEvent - trackEvent = trackEvents.trackEvent - }) - - afterEach(() => { - sinon.restore() }) describe('trackUserLoginSuccessEvent', () => { @@ -108,12 +103,21 @@ describe('track_event', () => { { 'appsec.events.users.login.success.track': 'true', '_dd.appsec.events.users.login.success.sdk': 'true', + 'appsec.events.users.login.success.usr.login': 'user_id', 'appsec.events.users.login.success.metakey1': 'metaValue1', 'appsec.events.users.login.success.metakey2': 'metaValue2', 'appsec.events.users.login.success.metakey3': 'metaValue3' }) expect(prioritySampler.setPriority) .to.have.been.calledOnceWithExactly(rootSpan, USER_KEEP, SAMPLING_MECHANISM_APPSEC) + expect(sample).to.have.been.calledOnceWithExactly(rootSpan) + expect(waf.run).to.have.been.calledOnceWithExactly({ + persistent: { + [LOGIN_SUCCESS]: null, + [USER_ID]: 'user_id', + [USER_LOGIN]: 'user_id' + } + }) }) it('should call setUser and addTags without metadata', () => { @@ -125,27 +129,50 @@ describe('track_event', () => { expect(setUserTags).to.have.been.calledOnceWithExactly(user, rootSpan) expect(rootSpan.addTags).to.have.been.calledOnceWithExactly({ 'appsec.events.users.login.success.track': 'true', - '_dd.appsec.events.users.login.success.sdk': 'true' + '_dd.appsec.events.users.login.success.sdk': 'true', + 'appsec.events.users.login.success.usr.login': 'user_id' }) expect(prioritySampler.setPriority) .to.have.been.calledOnceWithExactly(rootSpan, USER_KEEP, SAMPLING_MECHANISM_APPSEC) + expect(sample).to.have.been.calledOnceWithExactly(rootSpan) + expect(waf.run).to.have.been.calledOnceWithExactly({ + persistent: { + [LOGIN_SUCCESS]: null, + [USER_ID]: 'user_id', + [USER_LOGIN]: 'user_id' + } + }) }) - it('should call waf run with login success address', () => { - const user = { id: 'user_id' } + it('should call waf with user login', () => { + const user = { id: 'user_id', login: 'user_login' } trackUserLoginSuccessEvent(tracer, user) - sinon.assert.calledOnceWithExactly( - waf.run, - { persistent: { [LOGIN_SUCCESS]: null } } - ) + + expect(log.warn).to.not.have.been.called + expect(setUserTags).to.have.been.calledOnceWithExactly(user, rootSpan) + expect(rootSpan.addTags).to.have.been.calledOnceWithExactly({ + 'appsec.events.users.login.success.track': 'true', + '_dd.appsec.events.users.login.success.sdk': 'true', + 'appsec.events.users.login.success.usr.login': 'user_login' + }) + expect(prioritySampler.setPriority) + .to.have.been.calledOnceWithExactly(rootSpan, USER_KEEP, SAMPLING_MECHANISM_APPSEC) + expect(sample).to.have.been.calledOnceWithExactly(rootSpan) + expect(waf.run).to.have.been.calledOnceWithExactly({ + persistent: { + [LOGIN_SUCCESS]: null, + [USER_ID]: 'user_id', + [USER_LOGIN]: 'user_login' + } + }) }) }) describe('trackUserLoginFailureEvent', () => { it('should log warning when passed invalid userId', () => { - trackUserLoginFailureEvent(tracer, null, false) - trackUserLoginFailureEvent(tracer, [], false) + trackUserLoginFailureEvent(tracer, null, false, { key: 'value' }) + trackUserLoginFailureEvent(tracer, [], false, { key: 'value' }) expect(log.warn).to.have.been.calledTwice expect(log.warn.firstCall) @@ -159,7 +186,7 @@ describe('track_event', () => { it('should log warning when root span is not available', () => { rootSpan = undefined - trackUserLoginFailureEvent(tracer, 'user_id', false) + trackUserLoginFailureEvent(tracer, 'user_id', false, { key: 'value' }) expect(log.warn) .to.have.been.calledOnceWithExactly('[ASM] Root span not available in %s', 'trackUserLoginFailureEvent') @@ -168,7 +195,9 @@ describe('track_event', () => { it('should call addTags with metadata', () => { trackUserLoginFailureEvent(tracer, 'user_id', true, { - metakey1: 'metaValue1', metakey2: 'metaValue2', metakey3: 'metaValue3' + metakey1: 'metaValue1', + metakey2: 'metaValue2', + metakey3: 'metaValue3' }) expect(log.warn).to.not.have.been.called @@ -177,6 +206,7 @@ describe('track_event', () => { 'appsec.events.users.login.failure.track': 'true', '_dd.appsec.events.users.login.failure.sdk': 'true', 'appsec.events.users.login.failure.usr.id': 'user_id', + 'appsec.events.users.login.failure.usr.login': 'user_id', 'appsec.events.users.login.failure.usr.exists': 'true', 'appsec.events.users.login.failure.metakey1': 'metaValue1', 'appsec.events.users.login.failure.metakey2': 'metaValue2', @@ -184,11 +214,20 @@ describe('track_event', () => { }) expect(prioritySampler.setPriority) .to.have.been.calledOnceWithExactly(rootSpan, USER_KEEP, SAMPLING_MECHANISM_APPSEC) + expect(sample).to.have.been.calledOnceWithExactly(rootSpan) + expect(waf.run).to.have.been.calledOnceWithExactly({ + persistent: { + [LOGIN_FAILURE]: null, + [USER_LOGIN]: 'user_id' + } + }) }) it('should send false `usr.exists` property when the user does not exist', () => { trackUserLoginFailureEvent(tracer, 'user_id', false, { - metakey1: 'metaValue1', metakey2: 'metaValue2', metakey3: 'metaValue3' + metakey1: 'metaValue1', + metakey2: 'metaValue2', + metakey3: 'metaValue3' }) expect(log.warn).to.not.have.been.called @@ -197,6 +236,7 @@ describe('track_event', () => { 'appsec.events.users.login.failure.track': 'true', '_dd.appsec.events.users.login.failure.sdk': 'true', 'appsec.events.users.login.failure.usr.id': 'user_id', + 'appsec.events.users.login.failure.usr.login': 'user_id', 'appsec.events.users.login.failure.usr.exists': 'false', 'appsec.events.users.login.failure.metakey1': 'metaValue1', 'appsec.events.users.login.failure.metakey2': 'metaValue2', @@ -204,6 +244,13 @@ describe('track_event', () => { }) expect(prioritySampler.setPriority) .to.have.been.calledOnceWithExactly(rootSpan, USER_KEEP, SAMPLING_MECHANISM_APPSEC) + expect(sample).to.have.been.calledOnceWithExactly(rootSpan) + expect(waf.run).to.have.been.calledOnceWithExactly({ + persistent: { + [LOGIN_FAILURE]: null, + [USER_LOGIN]: 'user_id' + } + }) }) it('should call addTags without metadata', () => { @@ -215,18 +262,18 @@ describe('track_event', () => { 'appsec.events.users.login.failure.track': 'true', '_dd.appsec.events.users.login.failure.sdk': 'true', 'appsec.events.users.login.failure.usr.id': 'user_id', + 'appsec.events.users.login.failure.usr.login': 'user_id', 'appsec.events.users.login.failure.usr.exists': 'true' }) expect(prioritySampler.setPriority) .to.have.been.calledOnceWithExactly(rootSpan, USER_KEEP, SAMPLING_MECHANISM_APPSEC) - }) - - it('should call waf run with login failure address', () => { - trackUserLoginFailureEvent(tracer, 'user_id') - sinon.assert.calledOnceWithExactly( - waf.run, - { persistent: { [LOGIN_FAILURE]: null } } - ) + expect(sample).to.have.been.calledOnceWithExactly(rootSpan) + expect(waf.run).to.have.been.calledOnceWithExactly({ + persistent: { + [LOGIN_FAILURE]: null, + [USER_LOGIN]: 'user_id' + } + }) }) }) @@ -255,7 +302,10 @@ describe('track_event', () => { }) it('should call addTags with metadata', () => { - trackCustomEvent(tracer, 'custom_event', { metaKey1: 'metaValue1', metakey2: 'metaValue2' }) + trackCustomEvent(tracer, 'custom_event', { + metaKey1: 'metaValue1', + metakey2: 'metaValue2' + }) expect(log.warn).to.not.have.been.called expect(setUserTags).to.not.have.been.called @@ -267,6 +317,7 @@ describe('track_event', () => { }) expect(prioritySampler.setPriority) .to.have.been.calledOnceWithExactly(rootSpan, USER_KEEP, SAMPLING_MECHANISM_APPSEC) + expect(sample).to.have.been.calledOnceWithExactly(rootSpan) }) it('should call addTags without metadata', () => { @@ -280,42 +331,6 @@ describe('track_event', () => { }) expect(prioritySampler.setPriority) .to.have.been.calledOnceWithExactly(rootSpan, USER_KEEP, SAMPLING_MECHANISM_APPSEC) - }) - }) - - describe('trackEvent', () => { - it('should call addTags with safe mode', () => { - trackEvent('event', { metaKey1: 'metaValue1', metakey2: 'metaValue2' }, 'trackEvent', rootSpan, 'safe') - expect(rootSpan.addTags).to.have.been.calledOnceWithExactly({ - 'appsec.events.event.track': 'true', - '_dd.appsec.events.event.auto.mode': 'safe', - 'appsec.events.event.metaKey1': 'metaValue1', - 'appsec.events.event.metakey2': 'metaValue2' - }) - expect(prioritySampler.setPriority) - .to.have.been.calledOnceWithExactly(rootSpan, USER_KEEP, SAMPLING_MECHANISM_APPSEC) - }) - - it('should call addTags with extended mode', () => { - trackEvent('event', { metaKey1: 'metaValue1', metakey2: 'metaValue2' }, 'trackEvent', rootSpan, 'extended') - expect(rootSpan.addTags).to.have.been.calledOnceWithExactly({ - 'appsec.events.event.track': 'true', - '_dd.appsec.events.event.auto.mode': 'extended', - 'appsec.events.event.metaKey1': 'metaValue1', - 'appsec.events.event.metakey2': 'metaValue2' - }) - expect(prioritySampler.setPriority) - .to.have.been.calledOnceWithExactly(rootSpan, USER_KEEP, SAMPLING_MECHANISM_APPSEC) - }) - - it('should call standalone sample', () => { - trackEvent('event', undefined, 'trackEvent', rootSpan, undefined) - - expect(rootSpan.addTags).to.have.been.calledOnceWithExactly({ - 'appsec.events.event.track': 'true' - }) - expect(prioritySampler.setPriority) - .to.have.been.calledOnceWithExactly(rootSpan, USER_KEEP, SAMPLING_MECHANISM_APPSEC) expect(sample).to.have.been.calledOnceWithExactly(rootSpan) }) }) diff --git a/packages/dd-trace/test/appsec/telemetry.spec.js b/packages/dd-trace/test/appsec/telemetry.spec.js index a297ede3280..3eb3b8521b4 100644 --- a/packages/dd-trace/test/appsec/telemetry.spec.js +++ b/packages/dd-trace/test/appsec/telemetry.spec.js @@ -339,6 +339,17 @@ describe('Appsec Telemetry metrics', () => { expect(count).to.not.have.been.called }) }) + + describe('incrementMissingUserLoginMetric', () => { + it('should increment instrum.user_auth.missing_user_login metric', () => { + appsecTelemetry.incrementMissingUserLoginMetric('passport-local', 'login_success') + + expect(count).to.have.been.calledOnceWithExactly('instrum.user_auth.missing_user_login', { + framework: 'passport-local', + event_type: 'login_success' + }) + }) + }) }) describe('if disabled', () => { diff --git a/packages/dd-trace/test/appsec/user_tracking.spec.js b/packages/dd-trace/test/appsec/user_tracking.spec.js new file mode 100644 index 00000000000..651048d5515 --- /dev/null +++ b/packages/dd-trace/test/appsec/user_tracking.spec.js @@ -0,0 +1,696 @@ +'use strict' + +const assert = require('assert') + +const log = require('../../src/log') +const telemetry = require('../../src/appsec/telemetry') +const { SAMPLING_MECHANISM_APPSEC } = require('../../src/constants') +const standalone = require('../../src/appsec/standalone') +const waf = require('../../src/appsec/waf') + +describe('User Tracking', () => { + let currentTags + let rootSpan + let keepTrace + + let setCollectionMode + let trackLogin + + beforeEach(() => { + sinon.stub(log, 'warn') + sinon.stub(log, 'error') + sinon.stub(telemetry, 'incrementMissingUserLoginMetric') + sinon.stub(standalone, 'sample') + sinon.stub(waf, 'run').returns(['action1']) + + currentTags = {} + + rootSpan = { + context: () => ({ _tags: currentTags }), + addTags: sinon.stub() + } + + keepTrace = sinon.stub() + + const UserTracking = proxyquire('../src/appsec/user_tracking', { + '../priority_sampler': { keepTrace } + }) + + setCollectionMode = UserTracking.setCollectionMode + trackLogin = UserTracking.trackLogin + }) + + afterEach(() => { + sinon.restore() + }) + + describe('getUserId', () => { + beforeEach(() => { + setCollectionMode('identification') + }) + + it('should find an id field in user object', () => { + const user = { + notId: 'no', + id: '123', + email: 'a@b.c' + } + + const results = trackLogin('passport-local', 'login', user, true, rootSpan) + + assert.deepStrictEqual(results, ['action1']) + + sinon.assert.notCalled(log.error) + sinon.assert.notCalled(telemetry.incrementMissingUserLoginMetric) + + sinon.assert.calledOnceWithExactly(keepTrace, rootSpan, SAMPLING_MECHANISM_APPSEC) + sinon.assert.calledOnceWithExactly(standalone.sample, rootSpan) + sinon.assert.calledOnceWithExactly(rootSpan.addTags, { + 'appsec.events.users.login.success.track': 'true', + '_dd.appsec.events.users.login.success.auto.mode': 'identification', + '_dd.appsec.usr.login': 'login', + 'appsec.events.users.login.success.usr.login': 'login', + '_dd.appsec.usr.id': '123', + 'usr.id': '123' + }) + sinon.assert.calledOnceWithExactly(waf.run, { + persistent: { + 'usr.login': 'login', + 'usr.id': '123', + 'server.business_logic.users.login.success': null + } + }) + }) + + it('should find an id-like field in user object when no id field is present', () => { + const user = { + notId: 'no', + email: 'a@b.c', + username: 'azerty' + } + + const results = trackLogin('passport-local', 'login', user, true, rootSpan) + + assert.deepStrictEqual(results, ['action1']) + + sinon.assert.notCalled(log.error) + sinon.assert.notCalled(telemetry.incrementMissingUserLoginMetric) + + sinon.assert.calledOnceWithExactly(keepTrace, rootSpan, SAMPLING_MECHANISM_APPSEC) + sinon.assert.calledOnceWithExactly(standalone.sample, rootSpan) + sinon.assert.calledOnceWithExactly(rootSpan.addTags, { + 'appsec.events.users.login.success.track': 'true', + '_dd.appsec.events.users.login.success.auto.mode': 'identification', + '_dd.appsec.usr.login': 'login', + 'appsec.events.users.login.success.usr.login': 'login', + '_dd.appsec.usr.id': 'a@b.c', + 'usr.id': 'a@b.c' + }) + sinon.assert.calledOnceWithExactly(waf.run, { + persistent: { + 'usr.login': 'login', + 'usr.id': 'a@b.c', + 'server.business_logic.users.login.success': null + } + }) + }) + + it('should find a stringifiable id in user object', () => { + const stringifiableObject = { + a: 1, + toString: () => '123' + } + + const user = { + notId: 'no', + id: { a: 1 }, + _id: stringifiableObject, + email: 'a@b.c' + } + + const results = trackLogin('passport-local', 'login', user, true, rootSpan) + + assert.deepStrictEqual(results, ['action1']) + + sinon.assert.notCalled(log.error) + sinon.assert.notCalled(telemetry.incrementMissingUserLoginMetric) + + sinon.assert.calledOnceWithExactly(keepTrace, rootSpan, SAMPLING_MECHANISM_APPSEC) + sinon.assert.calledOnceWithExactly(standalone.sample, rootSpan) + sinon.assert.calledOnceWithExactly(rootSpan.addTags, { + 'appsec.events.users.login.success.track': 'true', + '_dd.appsec.events.users.login.success.auto.mode': 'identification', + '_dd.appsec.usr.login': 'login', + 'appsec.events.users.login.success.usr.login': 'login', + '_dd.appsec.usr.id': '123', + 'usr.id': '123' + }) + sinon.assert.calledOnceWithExactly(waf.run, { + persistent: { + 'usr.login': 'login', + 'usr.id': '123', + 'server.business_logic.users.login.success': null + } + }) + }) + }) + + describe('trackLogin', () => { + it('should not do anything if collectionMode is empty or disabled', () => { + setCollectionMode('disabled') + + const results = trackLogin('passport-local', 'login', { id: '123', email: 'a@b.c' }, true, rootSpan) + + assert.deepStrictEqual(results, undefined) + + sinon.assert.notCalled(log.error) + sinon.assert.notCalled(telemetry.incrementMissingUserLoginMetric) + sinon.assert.notCalled(keepTrace) + sinon.assert.notCalled(standalone.sample) + sinon.assert.notCalled(rootSpan.addTags) + sinon.assert.notCalled(waf.run) + }) + + it('should log error when rootSpan is not found', () => { + setCollectionMode('identification') + + const results = trackLogin('passport-local', 'login', { id: '123', email: 'a@b.c' }, true) + + assert.deepStrictEqual(results, undefined) + + sinon.assert.calledOnceWithExactly(log.error, '[ASM] No rootSpan found in AppSec trackLogin') + sinon.assert.notCalled(telemetry.incrementMissingUserLoginMetric) + sinon.assert.notCalled(keepTrace) + sinon.assert.notCalled(standalone.sample) + sinon.assert.notCalled(rootSpan.addTags) + sinon.assert.notCalled(waf.run) + }) + + it('should log error and send telemetry when login success is not a string', () => { + setCollectionMode('identification') + + const results = trackLogin('passport-local', {}, { id: '123', email: 'a@b.c' }, true, rootSpan) + + assert.deepStrictEqual(results, undefined) + + sinon.assert.calledOnceWithExactly(log.error, '[ASM] Invalid login provided to AppSec trackLogin') + sinon.assert.calledOnceWithExactly(telemetry.incrementMissingUserLoginMetric, 'passport-local', 'login_success') + sinon.assert.notCalled(keepTrace) + sinon.assert.notCalled(standalone.sample) + sinon.assert.notCalled(rootSpan.addTags) + sinon.assert.notCalled(waf.run) + }) + + it('should log error and send telemetry when login failure is not a string', () => { + setCollectionMode('identification') + + const results = trackLogin('passport-local', {}, { id: '123', email: 'a@b.c' }, false, rootSpan) + + assert.deepStrictEqual(results, undefined) + + sinon.assert.calledOnceWithExactly(log.error, '[ASM] Invalid login provided to AppSec trackLogin') + sinon.assert.calledOnceWithExactly(telemetry.incrementMissingUserLoginMetric, 'passport-local', 'login_failure') + sinon.assert.notCalled(keepTrace) + sinon.assert.notCalled(standalone.sample) + sinon.assert.notCalled(rootSpan.addTags) + sinon.assert.notCalled(waf.run) + }) + + describe('when collectionMode is indentification', () => { + beforeEach(() => { + setCollectionMode('identification') + }) + + it('should write tags and call waf when success is true', () => { + const results = trackLogin('passport-local', 'login', { id: '123', email: 'a@b.c' }, true, rootSpan) + + assert.deepStrictEqual(results, ['action1']) + + sinon.assert.notCalled(log.error) + sinon.assert.notCalled(telemetry.incrementMissingUserLoginMetric) + + sinon.assert.calledOnceWithExactly(keepTrace, rootSpan, SAMPLING_MECHANISM_APPSEC) + sinon.assert.calledOnceWithExactly(standalone.sample, rootSpan) + sinon.assert.calledOnceWithExactly(rootSpan.addTags, { + 'appsec.events.users.login.success.track': 'true', + '_dd.appsec.events.users.login.success.auto.mode': 'identification', + '_dd.appsec.usr.login': 'login', + 'appsec.events.users.login.success.usr.login': 'login', + '_dd.appsec.usr.id': '123', + 'usr.id': '123' + }) + sinon.assert.calledOnceWithExactly(waf.run, { + persistent: { + 'usr.login': 'login', + 'usr.id': '123', + 'server.business_logic.users.login.success': null + } + }) + }) + + it('should write tags and call waf when success is false', () => { + const results = trackLogin('passport-local', 'login', { id: '123', email: 'a@b.c' }, false, rootSpan) + + assert.deepStrictEqual(results, ['action1']) + + sinon.assert.notCalled(log.error) + sinon.assert.notCalled(telemetry.incrementMissingUserLoginMetric) + + sinon.assert.calledOnceWithExactly(keepTrace, rootSpan, SAMPLING_MECHANISM_APPSEC) + sinon.assert.calledOnceWithExactly(standalone.sample, rootSpan) + sinon.assert.calledOnceWithExactly(rootSpan.addTags, { + 'appsec.events.users.login.failure.track': 'true', + '_dd.appsec.events.users.login.failure.auto.mode': 'identification', + '_dd.appsec.usr.login': 'login', + 'appsec.events.users.login.failure.usr.login': 'login', + '_dd.appsec.usr.id': '123', + 'appsec.events.users.login.failure.usr.id': '123' + }) + sinon.assert.calledOnceWithExactly(waf.run, { + persistent: { + 'usr.login': 'login', + 'server.business_logic.users.login.failure': null + } + }) + }) + + it('should not overwrite tags set by SDK when success is true', () => { + currentTags = { + '_dd.appsec.events.users.login.success.sdk': 'true', + 'appsec.events.users.login.success.usr.login': 'sdk_login', + 'usr.id': 'sdk_id' + } + + const results = trackLogin('passport-local', 'login', { id: '123', email: 'a@b.c' }, true, rootSpan) + + assert.deepStrictEqual(results, ['action1']) + + sinon.assert.notCalled(log.error) + sinon.assert.notCalled(telemetry.incrementMissingUserLoginMetric) + + sinon.assert.calledOnceWithExactly(keepTrace, rootSpan, SAMPLING_MECHANISM_APPSEC) + sinon.assert.calledOnceWithExactly(standalone.sample, rootSpan) + sinon.assert.calledOnceWithExactly(rootSpan.addTags, { + 'appsec.events.users.login.success.track': 'true', + '_dd.appsec.events.users.login.success.auto.mode': 'identification', + '_dd.appsec.usr.login': 'login', + '_dd.appsec.usr.id': '123' + }) + sinon.assert.calledOnceWithExactly(waf.run, { + persistent: { + 'usr.login': 'login', + 'server.business_logic.users.login.success': null + } + }) + }) + + it('should not overwwrite tags set by SDK when success is false', () => { + currentTags = { + '_dd.appsec.events.users.login.failure.sdk': 'true', + 'appsec.events.users.login.failure.usr.login': 'sdk_login', + 'appsec.events.users.login.failure.usr.id': 'sdk_id' + } + + const results = trackLogin('passport-local', 'login', { id: '123', email: 'a@b.c' }, false, rootSpan) + + assert.deepStrictEqual(results, ['action1']) + + sinon.assert.notCalled(log.error) + sinon.assert.notCalled(telemetry.incrementMissingUserLoginMetric) + + sinon.assert.calledOnceWithExactly(keepTrace, rootSpan, SAMPLING_MECHANISM_APPSEC) + sinon.assert.calledOnceWithExactly(standalone.sample, rootSpan) + sinon.assert.calledOnceWithExactly(rootSpan.addTags, { + 'appsec.events.users.login.failure.track': 'true', + '_dd.appsec.events.users.login.failure.auto.mode': 'identification', + '_dd.appsec.usr.login': 'login', + '_dd.appsec.usr.id': '123' + }) + sinon.assert.calledOnceWithExactly(waf.run, { + persistent: { + 'usr.login': 'login', + 'server.business_logic.users.login.failure': null + } + }) + }) + + it('should write tags and call waf without user object when success is true', () => { + const results = trackLogin('passport-local', 'login', null, true, rootSpan) + + assert.deepStrictEqual(results, ['action1']) + + sinon.assert.notCalled(log.error) + sinon.assert.notCalled(telemetry.incrementMissingUserLoginMetric) + + sinon.assert.calledOnceWithExactly(keepTrace, rootSpan, SAMPLING_MECHANISM_APPSEC) + sinon.assert.calledOnceWithExactly(standalone.sample, rootSpan) + sinon.assert.calledOnceWithExactly(rootSpan.addTags, { + 'appsec.events.users.login.success.track': 'true', + '_dd.appsec.events.users.login.success.auto.mode': 'identification', + '_dd.appsec.usr.login': 'login', + 'appsec.events.users.login.success.usr.login': 'login' + }) + sinon.assert.calledOnceWithExactly(waf.run, { + persistent: { + 'usr.login': 'login', + 'server.business_logic.users.login.success': null + } + }) + }) + + it('should write tags and call waf without user object when success is false', () => { + const results = trackLogin('passport-local', 'login', null, false, rootSpan) + + assert.deepStrictEqual(results, ['action1']) + + sinon.assert.notCalled(log.error) + sinon.assert.notCalled(telemetry.incrementMissingUserLoginMetric) + + sinon.assert.calledOnceWithExactly(keepTrace, rootSpan, SAMPLING_MECHANISM_APPSEC) + sinon.assert.calledOnceWithExactly(standalone.sample, rootSpan) + sinon.assert.calledOnceWithExactly(rootSpan.addTags, { + 'appsec.events.users.login.failure.track': 'true', + '_dd.appsec.events.users.login.failure.auto.mode': 'identification', + '_dd.appsec.usr.login': 'login', + 'appsec.events.users.login.failure.usr.login': 'login' + }) + sinon.assert.calledOnceWithExactly(waf.run, { + persistent: { + 'usr.login': 'login', + 'server.business_logic.users.login.failure': null + } + }) + }) + }) + + describe('when collectionMode is anonymization', () => { + beforeEach(() => { + setCollectionMode('anonymization') + }) + + it('should write tags and call waf when success is true', () => { + const results = trackLogin('passport-local', 'login', { id: '123', email: 'a@b.c' }, true, rootSpan) + + assert.deepStrictEqual(results, ['action1']) + + sinon.assert.notCalled(log.error) + sinon.assert.notCalled(telemetry.incrementMissingUserLoginMetric) + + sinon.assert.calledOnceWithExactly(keepTrace, rootSpan, SAMPLING_MECHANISM_APPSEC) + sinon.assert.calledOnceWithExactly(standalone.sample, rootSpan) + sinon.assert.calledOnceWithExactly(rootSpan.addTags, { + 'appsec.events.users.login.success.track': 'true', + '_dd.appsec.events.users.login.success.auto.mode': 'anonymization', + '_dd.appsec.usr.login': 'anon_428821350e9691491f616b754cd8315f', + 'appsec.events.users.login.success.usr.login': 'anon_428821350e9691491f616b754cd8315f', + '_dd.appsec.usr.id': 'anon_a665a45920422f9d417e4867efdc4fb8', + 'usr.id': 'anon_a665a45920422f9d417e4867efdc4fb8' + }) + sinon.assert.calledOnceWithExactly(waf.run, { + persistent: { + 'usr.login': 'anon_428821350e9691491f616b754cd8315f', + 'usr.id': 'anon_a665a45920422f9d417e4867efdc4fb8', + 'server.business_logic.users.login.success': null + } + }) + }) + + it('should write tags and call waf when success is false', () => { + const results = trackLogin('passport-local', 'login', { id: '123', email: 'a@b.c' }, false, rootSpan) + + assert.deepStrictEqual(results, ['action1']) + + sinon.assert.notCalled(log.error) + sinon.assert.notCalled(telemetry.incrementMissingUserLoginMetric) + + sinon.assert.calledOnceWithExactly(keepTrace, rootSpan, SAMPLING_MECHANISM_APPSEC) + sinon.assert.calledOnceWithExactly(standalone.sample, rootSpan) + sinon.assert.calledOnceWithExactly(rootSpan.addTags, { + 'appsec.events.users.login.failure.track': 'true', + '_dd.appsec.events.users.login.failure.auto.mode': 'anonymization', + '_dd.appsec.usr.login': 'anon_428821350e9691491f616b754cd8315f', + 'appsec.events.users.login.failure.usr.login': 'anon_428821350e9691491f616b754cd8315f', + '_dd.appsec.usr.id': 'anon_a665a45920422f9d417e4867efdc4fb8', + 'appsec.events.users.login.failure.usr.id': 'anon_a665a45920422f9d417e4867efdc4fb8' + }) + sinon.assert.calledOnceWithExactly(waf.run, { + persistent: { + 'usr.login': 'anon_428821350e9691491f616b754cd8315f', + 'server.business_logic.users.login.failure': null + } + }) + }) + + it('should not overwrite tags set by SDK when success is true', () => { + currentTags = { + '_dd.appsec.events.users.login.success.sdk': 'true', + 'appsec.events.users.login.success.usr.login': 'sdk_login', + 'usr.id': 'sdk_id' + } + + const results = trackLogin('passport-local', 'login', { id: '123', email: 'a@b.c' }, true, rootSpan) + + assert.deepStrictEqual(results, ['action1']) + + sinon.assert.notCalled(log.error) + sinon.assert.notCalled(telemetry.incrementMissingUserLoginMetric) + + sinon.assert.calledOnceWithExactly(keepTrace, rootSpan, SAMPLING_MECHANISM_APPSEC) + sinon.assert.calledOnceWithExactly(standalone.sample, rootSpan) + sinon.assert.calledOnceWithExactly(rootSpan.addTags, { + 'appsec.events.users.login.success.track': 'true', + '_dd.appsec.events.users.login.success.auto.mode': 'anonymization', + '_dd.appsec.usr.login': 'anon_428821350e9691491f616b754cd8315f', + '_dd.appsec.usr.id': 'anon_a665a45920422f9d417e4867efdc4fb8' + }) + sinon.assert.calledOnceWithExactly(waf.run, { + persistent: { + 'usr.login': 'anon_428821350e9691491f616b754cd8315f', + 'server.business_logic.users.login.success': null + } + }) + }) + + it('should not overwwrite tags set by SDK when success is false', () => { + currentTags = { + '_dd.appsec.events.users.login.failure.sdk': 'true', + 'appsec.events.users.login.failure.usr.login': 'sdk_login', + 'appsec.events.users.login.failure.usr.id': 'sdk_id' + } + + const results = trackLogin('passport-local', 'login', { id: '123', email: 'a@b.c' }, false, rootSpan) + + assert.deepStrictEqual(results, ['action1']) + + sinon.assert.notCalled(log.error) + sinon.assert.notCalled(telemetry.incrementMissingUserLoginMetric) + + sinon.assert.calledOnceWithExactly(keepTrace, rootSpan, SAMPLING_MECHANISM_APPSEC) + sinon.assert.calledOnceWithExactly(standalone.sample, rootSpan) + sinon.assert.calledOnceWithExactly(rootSpan.addTags, { + 'appsec.events.users.login.failure.track': 'true', + '_dd.appsec.events.users.login.failure.auto.mode': 'anonymization', + '_dd.appsec.usr.login': 'anon_428821350e9691491f616b754cd8315f', + '_dd.appsec.usr.id': 'anon_a665a45920422f9d417e4867efdc4fb8' + }) + sinon.assert.calledOnceWithExactly(waf.run, { + persistent: { + 'usr.login': 'anon_428821350e9691491f616b754cd8315f', + 'server.business_logic.users.login.failure': null + } + }) + }) + + it('should write tags and call waf without user object when success is true', () => { + const results = trackLogin('passport-local', 'login', null, true, rootSpan) + + assert.deepStrictEqual(results, ['action1']) + + sinon.assert.notCalled(log.error) + sinon.assert.notCalled(telemetry.incrementMissingUserLoginMetric) + + sinon.assert.calledOnceWithExactly(keepTrace, rootSpan, SAMPLING_MECHANISM_APPSEC) + sinon.assert.calledOnceWithExactly(standalone.sample, rootSpan) + sinon.assert.calledOnceWithExactly(rootSpan.addTags, { + 'appsec.events.users.login.success.track': 'true', + '_dd.appsec.events.users.login.success.auto.mode': 'anonymization', + '_dd.appsec.usr.login': 'anon_428821350e9691491f616b754cd8315f', + 'appsec.events.users.login.success.usr.login': 'anon_428821350e9691491f616b754cd8315f' + }) + sinon.assert.calledOnceWithExactly(waf.run, { + persistent: { + 'usr.login': 'anon_428821350e9691491f616b754cd8315f', + 'server.business_logic.users.login.success': null + } + }) + }) + + it('should write tags and call waf without user object when success is false', () => { + const results = trackLogin('passport-local', 'login', null, false, rootSpan) + + assert.deepStrictEqual(results, ['action1']) + + sinon.assert.notCalled(log.error) + sinon.assert.notCalled(telemetry.incrementMissingUserLoginMetric) + + sinon.assert.calledOnceWithExactly(keepTrace, rootSpan, SAMPLING_MECHANISM_APPSEC) + sinon.assert.calledOnceWithExactly(standalone.sample, rootSpan) + sinon.assert.calledOnceWithExactly(rootSpan.addTags, { + 'appsec.events.users.login.failure.track': 'true', + '_dd.appsec.events.users.login.failure.auto.mode': 'anonymization', + '_dd.appsec.usr.login': 'anon_428821350e9691491f616b754cd8315f', + 'appsec.events.users.login.failure.usr.login': 'anon_428821350e9691491f616b754cd8315f' + }) + sinon.assert.calledOnceWithExactly(waf.run, { + persistent: { + 'usr.login': 'anon_428821350e9691491f616b754cd8315f', + 'server.business_logic.users.login.failure': null + } + }) + }) + }) + + describe('collectionMode aliases', () => { + it('should log warning and use anonymization mode when collectionMode is safe', () => { + setCollectionMode('safe') + + sinon.assert.calledOnceWithExactly( + log.warn, + '[ASM] Using deprecated value "safe" in config.appsec.eventTracking.mode' + ) + + const results = trackLogin('passport-local', 'login', { id: '123', email: 'a@b.c' }, true, rootSpan) + + assert.deepStrictEqual(results, ['action1']) + + sinon.assert.notCalled(log.error) + sinon.assert.notCalled(telemetry.incrementMissingUserLoginMetric) + + sinon.assert.calledOnceWithExactly(keepTrace, rootSpan, SAMPLING_MECHANISM_APPSEC) + sinon.assert.calledOnceWithExactly(standalone.sample, rootSpan) + sinon.assert.calledOnceWithExactly(rootSpan.addTags, { + 'appsec.events.users.login.success.track': 'true', + '_dd.appsec.events.users.login.success.auto.mode': 'anonymization', + '_dd.appsec.usr.login': 'anon_428821350e9691491f616b754cd8315f', + 'appsec.events.users.login.success.usr.login': 'anon_428821350e9691491f616b754cd8315f', + '_dd.appsec.usr.id': 'anon_a665a45920422f9d417e4867efdc4fb8', + 'usr.id': 'anon_a665a45920422f9d417e4867efdc4fb8' + }) + sinon.assert.calledOnceWithExactly(waf.run, { + persistent: { + 'usr.login': 'anon_428821350e9691491f616b754cd8315f', + 'usr.id': 'anon_a665a45920422f9d417e4867efdc4fb8', + 'server.business_logic.users.login.success': null + } + }) + }) + + it('should use anonymization mode when collectionMode is anon', () => { + setCollectionMode('anon') + + const results = trackLogin('passport-local', 'login', { id: '123', email: 'a@b.c' }, true, rootSpan) + + assert.deepStrictEqual(results, ['action1']) + + sinon.assert.notCalled(log.error) + sinon.assert.notCalled(telemetry.incrementMissingUserLoginMetric) + + sinon.assert.calledOnceWithExactly(keepTrace, rootSpan, SAMPLING_MECHANISM_APPSEC) + sinon.assert.calledOnceWithExactly(standalone.sample, rootSpan) + sinon.assert.calledOnceWithExactly(rootSpan.addTags, { + 'appsec.events.users.login.success.track': 'true', + '_dd.appsec.events.users.login.success.auto.mode': 'anonymization', + '_dd.appsec.usr.login': 'anon_428821350e9691491f616b754cd8315f', + 'appsec.events.users.login.success.usr.login': 'anon_428821350e9691491f616b754cd8315f', + '_dd.appsec.usr.id': 'anon_a665a45920422f9d417e4867efdc4fb8', + 'usr.id': 'anon_a665a45920422f9d417e4867efdc4fb8' + }) + sinon.assert.calledOnceWithExactly(waf.run, { + persistent: { + 'usr.login': 'anon_428821350e9691491f616b754cd8315f', + 'usr.id': 'anon_a665a45920422f9d417e4867efdc4fb8', + 'server.business_logic.users.login.success': null + } + }) + }) + + it('should log warning and use identification mode when collectionMode is extended', () => { + setCollectionMode('extended') + + sinon.assert.calledOnceWithExactly( + log.warn, + '[ASM] Using deprecated value "extended" in config.appsec.eventTracking.mode' + ) + + const results = trackLogin('passport-local', 'login', { id: '123', email: 'a@b.c' }, true, rootSpan) + + assert.deepStrictEqual(results, ['action1']) + + sinon.assert.notCalled(log.error) + sinon.assert.notCalled(telemetry.incrementMissingUserLoginMetric) + + sinon.assert.calledOnceWithExactly(keepTrace, rootSpan, SAMPLING_MECHANISM_APPSEC) + sinon.assert.calledOnceWithExactly(standalone.sample, rootSpan) + sinon.assert.calledOnceWithExactly(rootSpan.addTags, { + 'appsec.events.users.login.success.track': 'true', + '_dd.appsec.events.users.login.success.auto.mode': 'identification', + '_dd.appsec.usr.login': 'login', + 'appsec.events.users.login.success.usr.login': 'login', + '_dd.appsec.usr.id': '123', + 'usr.id': '123' + }) + sinon.assert.calledOnceWithExactly(waf.run, { + persistent: { + 'usr.login': 'login', + 'usr.id': '123', + 'server.business_logic.users.login.success': null + } + }) + }) + + it('should use identification mode when collectionMode is ident', () => { + setCollectionMode('ident') + + const results = trackLogin('passport-local', 'login', { id: '123', email: 'a@b.c' }, true, rootSpan) + + assert.deepStrictEqual(results, ['action1']) + + sinon.assert.notCalled(log.error) + sinon.assert.notCalled(telemetry.incrementMissingUserLoginMetric) + + sinon.assert.calledOnceWithExactly(keepTrace, rootSpan, SAMPLING_MECHANISM_APPSEC) + sinon.assert.calledOnceWithExactly(standalone.sample, rootSpan) + sinon.assert.calledOnceWithExactly(rootSpan.addTags, { + 'appsec.events.users.login.success.track': 'true', + '_dd.appsec.events.users.login.success.auto.mode': 'identification', + '_dd.appsec.usr.login': 'login', + 'appsec.events.users.login.success.usr.login': 'login', + '_dd.appsec.usr.id': '123', + 'usr.id': '123' + }) + sinon.assert.calledOnceWithExactly(waf.run, { + persistent: { + 'usr.login': 'login', + 'usr.id': '123', + 'server.business_logic.users.login.success': null + } + }) + }) + + it('should use disabled mode when collectionMode is not recognized', () => { + setCollectionMode('saperlipopette') + + const results = trackLogin('passport-local', 'login', { id: '123', email: 'a@b.c' }, true, rootSpan) + + assert.deepStrictEqual(results, undefined) + + sinon.assert.notCalled(log.error) + sinon.assert.notCalled(telemetry.incrementMissingUserLoginMetric) + sinon.assert.notCalled(keepTrace) + sinon.assert.notCalled(standalone.sample) + sinon.assert.notCalled(rootSpan.addTags) + sinon.assert.notCalled(waf.run) + }) + }) + }) +}) diff --git a/packages/dd-trace/test/config.spec.js b/packages/dd-trace/test/config.spec.js index 1eb711dbd2c..32afdf7c8f7 100644 --- a/packages/dd-trace/test/config.spec.js +++ b/packages/dd-trace/test/config.spec.js @@ -170,7 +170,7 @@ describe('Config', () => { it('should correctly map OTEL_RESOURCE_ATTRIBUTES', () => { process.env.OTEL_RESOURCE_ATTRIBUTES = - 'deployment.environment=test1,service.name=test2,service.version=5,foo=bar1,baz=qux1' + 'deployment.environment=test1,service.name=test2,service.version=5,foo=bar1,baz=qux1' const config = new Config() expect(config).to.have.property('env', 'test1') @@ -259,8 +259,7 @@ describe('Config', () => { expect(config).to.have.nested.property('appsec.blockedTemplateHtml', undefined) expect(config).to.have.nested.property('appsec.blockedTemplateJson', undefined) expect(config).to.have.nested.property('appsec.blockedTemplateGraphql', undefined) - expect(config).to.have.nested.property('appsec.eventTracking.enabled', true) - expect(config).to.have.nested.property('appsec.eventTracking.mode', 'safe') + expect(config).to.have.nested.property('appsec.eventTracking.mode', 'identification') expect(config).to.have.nested.property('appsec.apiSecurity.enabled', true) expect(config).to.have.nested.property('appsec.apiSecurity.sampleDelay', 30) expect(config).to.have.nested.property('appsec.sca.enabled', null) @@ -285,6 +284,7 @@ describe('Config', () => { { name: 'appsec.blockedTemplateHtml', value: undefined, origin: 'default' }, { name: 'appsec.blockedTemplateJson', value: undefined, origin: 'default' }, { name: 'appsec.enabled', value: undefined, origin: 'default' }, + { name: 'appsec.eventTracking.mode', value: 'identification', origin: 'default' }, { name: 'appsec.obfuscatorKeyRegex', // eslint-disable-next-line @stylistic/js/max-len @@ -603,7 +603,6 @@ describe('Config', () => { expect(config).to.have.nested.property('appsec.blockedTemplateHtml', BLOCKED_TEMPLATE_HTML) expect(config).to.have.nested.property('appsec.blockedTemplateJson', BLOCKED_TEMPLATE_JSON) expect(config).to.have.nested.property('appsec.blockedTemplateGraphql', BLOCKED_TEMPLATE_GRAPHQL) - expect(config).to.have.nested.property('appsec.eventTracking.enabled', true) expect(config).to.have.nested.property('appsec.eventTracking.mode', 'extended') expect(config).to.have.nested.property('appsec.apiSecurity.enabled', true) expect(config).to.have.nested.property('appsec.apiSecurity.sampleDelay', 25) @@ -635,6 +634,7 @@ describe('Config', () => { { name: 'appsec.blockedTemplateHtml', value: BLOCKED_TEMPLATE_HTML_PATH, origin: 'env_var' }, { name: 'appsec.blockedTemplateJson', value: BLOCKED_TEMPLATE_JSON_PATH, origin: 'env_var' }, { name: 'appsec.enabled', value: true, origin: 'env_var' }, + { name: 'appsec.eventTracking.mode', value: 'extended', origin: 'env_var' }, { name: 'appsec.obfuscatorKeyRegex', value: '.*', origin: 'env_var' }, { name: 'appsec.obfuscatorValueRegex', value: '.*', origin: 'env_var' }, { name: 'appsec.rateLimit', value: '42', origin: 'env_var' }, @@ -773,6 +773,15 @@ describe('Config', () => { expect(config).to.have.nested.deep.property('crashtracking.enabled', false) }) + it('should prioritize DD_APPSEC_AUTO_USER_INSTRUMENTATION_MODE over DD_APPSEC_AUTOMATED_USER_EVENTS_TRACKING', () => { + process.env.DD_APPSEC_AUTO_USER_INSTRUMENTATION_MODE = 'anonymous' + process.env.DD_APPSEC_AUTOMATED_USER_EVENTS_TRACKING = 'extended' + + const config = new Config() + + expect(config).to.have.nested.property('appsec.eventTracking.mode', 'anonymous') + }) + it('should initialize from the options', () => { const logger = {} const tags = { @@ -1187,6 +1196,7 @@ describe('Config', () => { process.env.DD_APPSEC_HTTP_BLOCKED_TEMPLATE_HTML = BLOCKED_TEMPLATE_JSON_PATH // note the inversion between process.env.DD_APPSEC_HTTP_BLOCKED_TEMPLATE_JSON = BLOCKED_TEMPLATE_HTML_PATH // json and html here process.env.DD_APPSEC_GRAPHQL_BLOCKED_TEMPLATE_JSON = BLOCKED_TEMPLATE_JSON_PATH // json and html here + process.env.DD_APPSEC_AUTO_USER_INSTRUMENTATION_MODE = 'disabled' process.env.DD_APPSEC_AUTOMATED_USER_EVENTS_TRACKING = 'disabled' process.env.DD_API_SECURITY_ENABLED = 'false' process.env.DD_REMOTE_CONFIG_POLL_INTERVAL_SECONDS = 11 @@ -1251,7 +1261,7 @@ describe('Config', () => { blockedTemplateJson: BLOCKED_TEMPLATE_JSON_PATH, blockedTemplateGraphql: BLOCKED_TEMPLATE_GRAPHQL_PATH, eventTracking: { - mode: 'safe' + mode: 'anonymous' }, apiSecurity: { enabled: true @@ -1329,8 +1339,7 @@ describe('Config', () => { expect(config).to.have.nested.property('appsec.blockedTemplateHtml', BLOCKED_TEMPLATE_HTML) expect(config).to.have.nested.property('appsec.blockedTemplateJson', BLOCKED_TEMPLATE_JSON) expect(config).to.have.nested.property('appsec.blockedTemplateGraphql', BLOCKED_TEMPLATE_GRAPHQL) - expect(config).to.have.nested.property('appsec.eventTracking.enabled', true) - expect(config).to.have.nested.property('appsec.eventTracking.mode', 'safe') + expect(config).to.have.nested.property('appsec.eventTracking.mode', 'anonymous') expect(config).to.have.nested.property('appsec.apiSecurity.enabled', true) expect(config).to.have.nested.property('remoteConfig.pollInterval', 42) expect(config).to.have.nested.property('iast.enabled', true) @@ -1392,7 +1401,7 @@ describe('Config', () => { blockedTemplateJson: BLOCKED_TEMPLATE_JSON_PATH, blockedTemplateGraphql: BLOCKED_TEMPLATE_GRAPHQL_PATH, eventTracking: { - mode: 'safe' + mode: 'anonymous' }, apiSecurity: { enabled: false @@ -1427,7 +1436,6 @@ describe('Config', () => { blockedTemplateJson: undefined, blockedTemplateGraphql: undefined, eventTracking: { - enabled: false, mode: 'disabled' }, apiSecurity: {